├── .gitignore
├── LICENSE
├── README.md
├── anticheat
├── __init__.py
├── run.py
└── utils
│ ├── __init__.py
│ └── beatmap.py
├── config.sample.py
├── constants
├── __init__.py
├── beatmap.py
├── commands.py
├── levels.py
├── match.py
├── mods.py
├── packets.py
├── player.py
└── playmode.py
├── decorators.py
├── events
├── __init__.py
├── avatar.py
├── bancho.py
└── osu.py
├── lib
├── __init__.py
└── database.py
├── objects
├── __init__.py
├── beatmap.py
├── bot.py
├── channel.py
├── collections.py
├── glob.py
├── match.py
├── player.py
└── score.py
├── packets
├── __init__.py
├── reader.py
└── writer.py
├── requirements.txt
├── server.py
└── utils
├── __init__.py
├── general.py
├── log.py
├── replay.py
└── score.py
/.gitignore:
--------------------------------------------------------------------------------
1 | **/__pycache__
2 | **/build
3 |
4 | /config.py
5 | /.data
6 |
7 | Pipfile
8 | Pipfile.lock
9 |
10 | .vscode
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published by
637 | the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ragnarok - an osu! private server
2 | ## NOTE: Aoba and I, have been working on rewriting Ragnarok to Vlang, so this won't be updated until we're up to date with Ragnarok in v
3 | Ragnarok is both a bancho and /web/ server, written in python3.9!
4 |
5 | Ragnarok will provide more stablibilty:tm: and way faster performance than Ripple's bancho emulator (Second login takes about 4-5ms).
6 |
7 | Note: Ragnarok does not work on windows.
8 |
9 | ## Setup
10 | We will not help setting up the whole server (nginx, mysql and those stuff), but just the bancho.
11 |
12 | We suggest making an environment before doing anything. You can create one by installing pipenv.
13 | ```
14 | $ python3.9 -m pip install pipenv
15 | ...
16 |
17 | $ python3.9 -m pipenv install
18 | Creating a virtualenv for this project...
19 | ...
20 |
21 | $ pipenv shell
22 | ```
23 |
24 | After that you can install the requirements.
25 | ```
26 | $ pip install -r requirements.txt
27 | ```
28 |
29 | Once that's finished, you can go ahead and make a copy of the config.sample.py, by doing:
30 | ```
31 | $ mv config.sample.py config.py
32 | $ nano config.py
33 | ```
34 |
35 | Then you can go ahead and change the needed stuff in there. *MARKED WITH "CHANGE THIS"*
36 |
37 | And the last thing you have to do, is running the server.
38 | ```
39 | $ python server.py
40 | ```
41 |
42 | If there's any issues during setup, feel free to post an issue.
43 |
44 | ## Requirements
45 | Experience developing in Python.
46 |
47 | ## License
48 | Ragnarok's code is licensed under the [GNU Affero General Public License v3 licence](https://tldrlegal.com/license/gnu-affero-general-public-license-v3-(agpl-3.0)). Please see [the licence file](https://github.com/osumitsuha/Ragnarok/blob/main/LICENSE) for more information.
49 |
--------------------------------------------------------------------------------
/anticheat/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osumitsuha/Ragnarok/948214e20d4669ad1fe22416552665174f70363c/anticheat/__init__.py
--------------------------------------------------------------------------------
/anticheat/run.py:
--------------------------------------------------------------------------------
1 | from anticheat.utils.beatmap import Beatmap
2 | from osrparse import parse_replay
3 | from constants.mods import Mods
4 | from utils.replay import write_replay
5 | from constants.playmode import Mode
6 |
7 |
8 | async def run_anticheat(score, score_file_name: int, beatmap_file_name: str):
9 | if score.mode != Mode.STD:
10 | return
11 |
12 | r = parse_replay(await write_replay(s=score, file_name=score_file_name))
13 |
14 | hitobjects = await Beatmap().parse_hitobjects(
15 | beatmap_file_name, hr=r.mod_combination & Mods.HARDROCK
16 | )
17 |
18 | c_aim = 0
19 | for aim in r.play_data:
20 | aim.xy = aim.x + aim.y
21 |
22 | for obj in hitobjects:
23 | if aim.x == obj.x and aim.y == obj.y:
24 | c_aim += 1
25 |
26 | # I'm not 100% sure, if this works or not lol
27 |
28 | # print(f"{c_aim} / {len(hitobjects)} * 100 = {c_aim / len(hitobjects) * 100}")
29 |
--------------------------------------------------------------------------------
/anticheat/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osumitsuha/Ragnarok/948214e20d4669ad1fe22416552665174f70363c/anticheat/utils/__init__.py
--------------------------------------------------------------------------------
/anticheat/utils/beatmap.py:
--------------------------------------------------------------------------------
1 | import aiofiles
2 |
3 |
4 | class HitObject:
5 | def __init__(self):
6 | self.time: int = 0
7 |
8 | self.x: int = 0
9 | self.y: int = 0
10 | self.xy: int = 0
11 |
12 | @classmethod
13 | def from_str(cls, line, hr: bool = False):
14 | args = line.split(",")
15 |
16 | s = cls()
17 |
18 | s.time = int(args[2])
19 | s.x = int(args[0])
20 | s.y = 384 - int(args[1]) if hr else int(args[1])
21 |
22 | s.xy = s.x + s.y
23 |
24 | return s
25 |
26 |
27 | class Beatmap(list):
28 | async def parse_hitobjects(self, file_name: str, hr: bool = False):
29 | lines = None
30 |
31 | async with aiofiles.open(file_name, "r") as file:
32 | lines = await file.readlines()
33 |
34 | for idx, line in enumerate(lines):
35 | if line == "[HitObjects]\n":
36 | lines = lines[idx + 1 :]
37 |
38 | for line in lines:
39 | super().append(HitObject.from_str(line))
40 |
41 | return self
42 |
--------------------------------------------------------------------------------
/config.sample.py:
--------------------------------------------------------------------------------
1 | conf = {
2 | "server": {"debug": False, "domain": "mitsuha.pw", "port": 8000},
3 | "mysql": {
4 | "host": "localhost",
5 | "user": "CHANGE THIS",
6 | "password": "CHANGE THIS",
7 | "db": "CHANGE THIS",
8 | "autocommit": True,
9 | },
10 | "api_conf": {
11 | "osu_api_key": "CHANGE THIS",
12 | "mirrors": {
13 | "chimu": "https://api.chimu.moe/v1/",
14 | "katsu": "https://katsu.moe/"
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/constants/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osumitsuha/Ragnarok/948214e20d4669ad1fe22416552665174f70363c/constants/__init__.py
--------------------------------------------------------------------------------
/constants/beatmap.py:
--------------------------------------------------------------------------------
1 | from enum import IntEnum
2 |
3 |
4 | class Approved(IntEnum):
5 | GRAVEYARD = -2
6 | WIP = -1
7 | PENDING = 0
8 |
9 | RANKED = 1
10 | APPROVED = 2
11 | QUALIFIED = 3
12 | LOVED = 4
13 |
--------------------------------------------------------------------------------
/constants/commands.py:
--------------------------------------------------------------------------------
1 | from constants.match import SlotStatus, ScoringType
2 | from constants.packets import BanchoPackets
3 | from constants.player import Privileges
4 | from constants.beatmap import Approved
5 | from dataclasses import dataclass
6 | from typing import TYPE_CHECKING
7 | from objects.bot import Louise
8 | from typing import Callable
9 | from packets import writer
10 | from typing import Union
11 | from objects import glob
12 | from utils import log
13 | import asyncio
14 | import random
15 | import copy
16 | import uuid
17 | import time
18 |
19 | if TYPE_CHECKING:
20 | from objects.channel import Channel
21 | from objects.player import Player
22 |
23 |
24 | @dataclass
25 | class Context:
26 | author: "Player"
27 | reciever: Union["Channel", "Player"]
28 |
29 | cmd: str
30 | args: list[str]
31 |
32 |
33 | @dataclass
34 | class Command:
35 | trigger: Callable
36 | cmd: str
37 | aliases: list[str]
38 |
39 | perms: Privileges
40 | doc: str
41 | hidden: bool
42 |
43 |
44 | commands: list["Command"] = []
45 | mp_commands: list["Command"] = []
46 |
47 |
48 | def rmp_command(
49 | trigger: str,
50 | required_perms: Privileges = Privileges.USER,
51 | hidden: str = False,
52 | aliases: list[str] = [],
53 | ):
54 | def decorator(cb: Callable) -> Callable:
55 | cmd = Command(
56 | trigger=cb,
57 | cmd=trigger,
58 | aliases=aliases,
59 | perms=required_perms,
60 | doc=cb.__doc__,
61 | hidden=hidden,
62 | )
63 |
64 | mp_commands.append(cmd)
65 |
66 | return decorator
67 |
68 |
69 | def register_command(
70 | trigger: str,
71 | required_perms: Privileges = Privileges.USER,
72 | hidden: str = False,
73 | aliases: tuple[str] = (),
74 | ):
75 | def decorator(cb: Callable) -> Callable:
76 | cmd = Command(
77 | trigger=cb,
78 | cmd=trigger,
79 | aliases=aliases,
80 | perms=required_perms,
81 | doc=cb.__doc__,
82 | hidden=hidden,
83 | )
84 |
85 | commands.append(cmd)
86 |
87 | return decorator
88 |
89 |
90 | #
91 | # Normal user commands
92 | #
93 |
94 |
95 | @register_command("help")
96 | async def help(ctx: Context) -> str:
97 | """The help message"""
98 |
99 | if ctx.args:
100 | trigger = ctx.args[0]
101 |
102 | for key in commands:
103 | if key.cmd != trigger:
104 | continue
105 |
106 | if key.hidden:
107 | continue
108 |
109 | if not key.perms & ctx.author.privileges:
110 | continue
111 |
112 | return f"{glob.prefix}{key.cmd} | Needed privileges ~> {key.perms.name}\nDescription: {key.doc}"
113 |
114 | visible_cmds = [
115 | cmd.cmd
116 | for cmd in commands
117 | if not cmd.hidden and cmd.perms & ctx.author.privileges
118 | ]
119 |
120 | return "List of all commands.\n " + "|".join(visible_cmds)
121 |
122 |
123 | @register_command("ping")
124 | async def ping_command(ctx: Context) -> str:
125 | """Ping the server, to see if it responds."""
126 |
127 | return "PONG"
128 |
129 |
130 | @register_command("roll")
131 | async def roll(ctx: Context) -> str:
132 | """Roll a dice!"""
133 |
134 | x = 100
135 |
136 | if len(ctx.args) > 1:
137 | x = int(ctx.args[1])
138 |
139 | return f"{ctx.author.username} rolled {random.randint(0, x)} point(s)"
140 |
141 |
142 | @register_command("last_np", hidden=True)
143 | async def last_np(ctx: Context) -> str:
144 | if not ctx.author.last_np:
145 | return "No np."
146 |
147 | return ctx.author.last_np.full_title
148 |
149 | @register_command("stats")
150 | async def user_stats(ctx: Context) -> str:
151 | """Display a users stats both vanilla or relax."""
152 |
153 | if len(ctx.args) < 1:
154 | return "Usage: !stats "
155 |
156 | if not (t := await glob.players.get_user_offline(ctx.args[0])):
157 | return "Player isn't online or couldn't be found in the database"
158 |
159 | relax = 0
160 |
161 | if len(ctx.args) == 2:
162 | if ctx.args[1] == "rx":
163 | relax = 1
164 |
165 | ret = await t.get_stats(relax)
166 |
167 | return (
168 | f"Stats for {t.username}:\n"
169 | f"PP: {ret['pp']} (#{ret['rank']})\n"
170 | f"Plays: {ret['playcount']} (lv{ret['level']})\n"
171 | f"Accuracy: {ret['level']}%"
172 | )
173 |
174 |
175 | @register_command("verify", required_perms=Privileges.PENDING)
176 | async def verify_with_key(ctx: Context) -> str:
177 | """Verify your account with our key system!"""
178 |
179 | if type(ctx.reciever) is not type(glob.bot):
180 | return "This command only works in BanchoBot's PMs."
181 |
182 | if not ctx.args:
183 | return "Usage: !verify "
184 |
185 | key = ctx.args[0]
186 |
187 | if not (
188 | key_info := await glob.sql.fetch(
189 | "SELECT id, beta_key, made FROM beta_keys WHERE beta_key = %s", (key)
190 | )
191 | ):
192 | return "Invalid key"
193 |
194 | asyncio.create_task(
195 | glob.sql.execute(
196 | "UPDATE users SET privileges = %s WHERE id = %s",
197 | (Privileges.USER.value + Privileges.VERIFIED.value, ctx.author.id),
198 | )
199 | )
200 |
201 | asyncio.create_task(
202 | glob.sql.execute("DELETE FROM beta_keys WHERE id = %s", key_info["id"])
203 | )
204 |
205 | ctx.author.privileges = Privileges.USER + Privileges.VERIFIED
206 | ctx.author.enqueue(
207 | await writer.Notification(
208 | "Welcome to Ragnarok. You've successfully verified your account and gained beta access! If you see any bugs or anything unusal, please report it to one of the developers, through Github issues or Discord."
209 | )
210 | )
211 |
212 | log.info(f"{ctx.author.username} successfully verified their account with a key")
213 |
214 | return "Successfully verified your account."
215 |
216 |
217 | #
218 | # Multiplayer commands
219 | #
220 |
221 |
222 | @rmp_command("help")
223 | async def multi_help(ctx: Context) -> str:
224 | """Multiplayer help command"""
225 | return "Not done yet."
226 |
227 |
228 | @rmp_command("start")
229 | async def start_match(ctx: Context) -> str:
230 | """Start the multiplayer when all players are ready or force start it."""
231 | if (
232 | not ctx.reciever.is_multi
233 | (m := ctx.author.match) or
234 | ctx.author.match.host != ctx.author.i
235 | ):
236 | return
237 |
238 | m = ctx.author.match
239 |
240 | if ctx.args:
241 | if ctx.args[0] == "force":
242 | for slot in m.slots:
243 | if slot.status & SlotStatus.OCCUPIED:
244 | if slot.status != SlotStatus.NOMAP:
245 | slot.status = SlotStatus.PLAYING
246 | slot.p.enqueue(await writer.MatchStart(m))
247 |
248 | await m.enqueue_state(lobby=True)
249 | return "Starting match... Good luck!"
250 |
251 | if not all(
252 | slot.status == SlotStatus.READY
253 | for slot in m.slots
254 | if slot.status & SlotStatus.OCCUPIED
255 | ):
256 | return "All players aren't ready. The command for force starting a match is !mp start force"
257 |
258 | for slot in m.slots:
259 | if slot.status & SlotStatus.OCCUPIED:
260 | slot.status = SlotStatus.PLAYING
261 |
262 | m.in_progress = True
263 |
264 | m.enqueue(await writer.MatchStart(m))
265 | await m.enqueue_state()
266 |
267 |
268 | @rmp_command("abort", aliases=("ab"))
269 | async def abort_match(ctx: Context) -> str:
270 | if (
271 | not ctx.reciever.is_multi
272 | (m := ctx.author.match) or
273 | not m.in_progress or
274 | m.host != ctx.author.id
275 | ): return
276 |
277 | for s in m.slots:
278 | if s.status == SlotStatus.PLAYING:
279 | s.p.enqueue(await writer.write(BanchoPackets.CHO_MATCH_ABORT))
280 | s.status = SlotStatus.NOTREADY
281 |
282 | s.skipped = False
283 | s.loaded = False
284 |
285 | m.in_progress = False
286 |
287 | await m.enqueue_state(lobby=True)
288 | return "Aborted match."
289 |
290 |
291 | @rmp_command("win", aliases=("wc"))
292 | async def win_condition(ctx: Context) -> str:
293 | """Change win condition in a multiplayer match."""
294 | if (
295 | not ctx.reciever.is_multi or
296 | not (m := ctx.author.match) or
297 | ctx.author.match.host != ctx.author.id
298 | ):
299 | return
300 |
301 | if not ctx.args:
302 | return f"Wrong usage. !multi {ctx.cmd} "
303 |
304 | if ctx.args[0] in ("score", "acc", "sv2", "combo"):
305 | old_scoring = copy.copy(m.scoring_type)
306 | m.scoring_type = ScoringType.find_value(ctx.args[0])
307 |
308 | await m.enqueue_state()
309 | return f"Changed win condition from {old_scoring.name.lower()} to {m.scoring_type.name.lower()}"
310 | elif ctx.args[0] == "pp":
311 | m.scoring_type = ScoringType.SCORE # force it to be score
312 | m.pp_win_condition = True
313 |
314 | await m.enqueue_state()
315 | return "Changed win condition to pp. THIS IS IN BETA AND CAN BE REMOVED ANY TIME."
316 |
317 | return "Not a valid win condition"
318 |
319 |
320 | @rmp_command("move")
321 | async def move_slot(ctx: Context) -> str:
322 | if (
323 | not ctx.reciever.is_multi or
324 | not (m := ctx.author.match) or
325 | ctx.author.match.host != ctx.author.id
326 | ):
327 | return
328 |
329 | if len(ctx.args) < 2:
330 | return "Wrong usage: !multi move "
331 |
332 | ctx.args[1] = int(ctx.args[1]) - 1
333 |
334 | player = glob.players.get_user(ctx.args[0])
335 |
336 | if not (target := m.find_user(player)):
337 | return "Slot is not occupied."
338 |
339 | if (to := m.find_slot(ctx.args[1])).status & SlotStatus.OCCUPIED:
340 | return "That slot is already occupied."
341 |
342 | to.copy_from(target)
343 | target.reset()
344 |
345 | await m.enqueue_state(lobby=True)
346 |
347 | return f"Moved {to.p.username} to slot {ctx.args[1] + 1}"
348 |
349 |
350 | @rmp_command("size")
351 | async def change_size(ctx: Context) -> str:
352 | if (
353 | not ctx.reciever.is_multi or
354 | not (m := ctx.author.match) or
355 | ctx.author.match.host != ctx.author.id or
356 | m.in_progress
357 | ):
358 | return
359 |
360 | if not ctx.args:
361 | return "Wrong usage: !multi size "
362 |
363 | for slot_id in range(0, int(ctx.args[0])):
364 | slot = m.find_slot(slot_id)
365 |
366 | if not slot.status & SlotStatus.OCCUPIED:
367 | slot.status = SlotStatus.LOCKED
368 |
369 | return f"Changed size to {ctx.args[0]}"
370 |
371 | @rmp_command("get")
372 | async def get_beatmap(ctx: Context) -> str:
373 | if (
374 | not ctx.reciever.is_multi or
375 | not (m := ctx.author.match)
376 | ):
377 | return
378 |
379 | if not ctx.args:
380 | return "Wrong usage: !multi get "
381 |
382 | if m.map_id == 0:
383 | return "The host has probably choosen a map that needs to be updated! Tell them to do so!"
384 |
385 | if ctx.args[0] not in (mirrors := glob.config["api_conf"]["mirrors"]):
386 | return "Mirror doesn't exist in our database"
387 |
388 | url = mirrors[ctx.args[0]]
389 |
390 | if ctx.args[0] == "chimu":
391 | url += f"download/{m.map_id}"
392 |
393 | elif ctx.args[0] == "katsu":
394 | url += f"d/{m.map_id}"
395 |
396 | return f"[{url} Download beatmap from {ctx.args[0]}]"
397 |
398 |
399 | @rmp_command("invite")
400 | async def invite_people(ctx: Context) -> str:
401 | if (
402 | not ctx.reciever.is_multi or
403 | not (m := ctx.author.match)
404 | ):
405 | return
406 |
407 | if not ctx.args:
408 | return "Wrong usage: !multi invite "
409 |
410 | if not (target := glob.players.get_user(ctx.args[0])):
411 | return "The user is not online."
412 |
413 | if target is ctx.author:
414 | return "You can't invite yourself."
415 |
416 | await ctx.author.send_message(
417 | f"Come join my multiplayer match: [osump://{m.match_id}/{m.match_pass.replace(' ', '_')} {m.match_name}]",
418 | reciever=target
419 | )
420 |
421 | return f"Invited {target.username}"
422 |
423 | #
424 | # Staff commands
425 | #
426 |
427 |
428 | @register_command("announce", required_perms=Privileges.MODERATOR)
429 | async def announce(ctx: Context) -> str:
430 | if len(ctx.args) < 2:
431 | return
432 |
433 | msg = " ".join(ctx.args[1:])
434 |
435 | if ctx.args[0] == "all":
436 | glob.players.enqueue(await writer.Notification(msg))
437 | else:
438 | if not (target := glob.players.get_user(ctx.args[0])):
439 | return "Player is not online."
440 |
441 | target.enqueue(await writer.Notification(msg))
442 |
443 | return "ok"
444 |
445 | @register_command("kick", required_perms=Privileges.MODERATOR)
446 | async def kick_user(ctx: Context) -> str:
447 | """Kick all players or just one player from the server."""
448 |
449 | if not ctx.args:
450 | return "Usage: !kick "
451 |
452 | if ctx.args[0].lower() == "all":
453 | for p in glob.players.players[:]:
454 | if (p == ctx.author) or p.bot:
455 | continue
456 |
457 | await p.logout()
458 |
459 | return "Kicked every. single. user online."
460 |
461 | if not (t := await glob.players.get_user_offline(" ".join(ctx.args))):
462 | return "Player isn't online or couldn't be found in the database"
463 |
464 | await t.logout()
465 | t.enqueue(await writer.Notification("You've been kicked!"))
466 |
467 | return f"Successfully kicked {t.username}"
468 |
469 |
470 | @register_command("restrict", required_perms=Privileges.ADMIN)
471 | async def restrict_user(ctx: Context) -> str:
472 | """Restrict users from the server"""
473 |
474 | if (not ctx.reciever == glob.bot) and ctx.reciever.name != "#staff":
475 | return "You can't do that here."
476 |
477 | if len(ctx.args) < 1:
478 | return "Usage: !restrict "
479 |
480 | if not (t := await glob.players.get_user_offline(" ".join(ctx.args))):
481 | return "Player isn't online or couldn't be found in the database"
482 |
483 | if t.is_restricted:
484 | return "Player is already restricted? Did you mean to unrestrict them?"
485 |
486 | asyncio.create_task(
487 | glob.sql.execute(
488 | "UPDATE users SET privileges = privileges - 4 WHERE id = %s", (t.id)
489 | )
490 | )
491 |
492 | t.privileges -= Privileges.VERIFIED
493 |
494 | t.enqueue(
495 | await writer.Notification("An admin has set your account in restricted mode!")
496 | )
497 |
498 | return f"Successfully restricted {t.username}"
499 |
500 |
501 | @register_command("unrestrict", required_perms=Privileges.ADMIN)
502 | async def unrestrict_user(ctx: Context) -> str:
503 | """Unrestrict users from the server."""
504 |
505 | if ctx.reciever != "#staff":
506 | return "You can't do that here."
507 |
508 | if len(ctx.args) < 1:
509 | return "Usage: !unrestrict "
510 |
511 | if not (t := await glob.players.get_user_offline(" ".join(ctx.args))):
512 | return "Player isn't online or couldn't be found in the database"
513 |
514 | if not t.is_restricted:
515 | return "Player isn't even restricted?"
516 |
517 | await glob.sql.execute(
518 | "UPDATE users SET privileges = privileges + 4 WHERE id = %s", (t.id)
519 | )
520 |
521 | t.privileges += Privileges.VERIFIED
522 |
523 | t.enqueue(await writer.Notification("An admin has unrestricted your account!"))
524 |
525 | return f"Successfully unrestricted {t.username}"
526 |
527 |
528 | @register_command("bot", required_perms=Privileges.DEV)
529 | async def bot_commands(ctx: Context) -> str:
530 | """Handle our bot ingame"""
531 |
532 | if not ctx.args:
533 | return f"{glob.bot.username.lower()}."
534 |
535 | if ctx.args[0] == "reconnect":
536 | if glob.players.get_user(1):
537 | return f"{glob.bot.username} is already connected."
538 |
539 | await Louise.init()
540 |
541 | return f"Successfully connected {glob.bot.username}."
542 |
543 |
544 | @register_command("approve")
545 | async def approve_map(ctx: Context) -> str:
546 | """Change the ranked status of beatmaps."""
547 |
548 | if not ctx.author.last_np:
549 | return "Please /np a map first."
550 |
551 | if ctx.author.last_np.hash_md5 in glob.beatmaps:
552 | _map = glob.beatmaps[ctx.author.last_np.hash_md5]
553 | else:
554 | _map = ctx.author.last_np
555 |
556 | if len(ctx.args) != 2:
557 | return "Usage: !approve "
558 |
559 | if not ctx.args[0] in ("map", "set"):
560 | return "Invalid first argument (map or set)"
561 |
562 | if not ctx.args[1] in ("rank", "love", "unrank"):
563 | return "Invalid approved status (rank, love or unrank)"
564 |
565 | ranked_status = {
566 | "rank": Approved.RANKED,
567 | "love": Approved.LOVED,
568 | "unrank": Approved.PENDING,
569 | }[ctx.args[1]]
570 |
571 | if _map.approved == ranked_status.value:
572 | return f"Map is already {ranked_status.name}"
573 |
574 | set_or_map = ctx.args[0] == "map"
575 |
576 | await glob.sql.execute(
577 | "UPDATE beatmaps SET approved = %s "
578 | f"WHERE {'map_id' if set_or_map else 'set_id'} = %s LIMIT 1",
579 | (ranked_status.value, _map.map_id if set_or_map else _map.set_id),
580 | )
581 |
582 | resp = f"Successfully changed {_map.full_title}'s status, from {Approved(_map.approved).name} to {ranked_status.name}"
583 |
584 | if ctx.author.last_np.hash_md5 in glob.beatmaps:
585 | _map.approved = ranked_status + 1
586 |
587 | return resp
588 |
589 |
590 | @register_command("key", required_perms=Privileges.ADMIN)
591 | async def beta_keys(ctx: Context) -> str:
592 | """Create or delete keys."""
593 |
594 | if len(ctx.args) < 1:
595 | return "Usage: !key "
596 |
597 | if ctx.args[0] == "create":
598 | if len(ctx.args) != 2:
599 | key = uuid.uuid4().hex
600 |
601 | asyncio.create_task(
602 | glob.sql.execute(
603 | "INSERT INTO beta_keys VALUES (NULL, %s, %s)",
604 | (key, time.time() + 432000),
605 | )
606 | )
607 |
608 | return f"Created key with the name {key}"
609 |
610 | key = ctx.args[1]
611 |
612 | asyncio.create_task(
613 | glob.sql.execute(
614 | "INSERT INTO beta_keys VALUES (NULL, %s, %s)",
615 | (key, time.time() + 432000),
616 | )
617 | )
618 |
619 | return f"Created key with the name {key}"
620 |
621 | elif ctx.args[0] == "delete":
622 | if len(ctx.args) != 2:
623 | return "Usage: !key delete "
624 |
625 | key_id = ctx.args[1]
626 |
627 | if not await glob.sql.fetch("SELECT 1 FROM beta_keys WHERE id = %s", (key_id)):
628 | return "Key doesn't exist"
629 |
630 | asyncio.create_task(
631 | glob.sql.execute("DELETE FROM beta_keys WHERE id = %s", (key_id))
632 | )
633 |
634 | return f"Deleted key {key_id}"
635 |
636 | return "Usage: !key "
637 |
638 |
639 | async def handle_commands(
640 | message: str, sender: "Player", reciever: Union["Channel", "Player"]
641 | ) -> None:
642 | if message[:6] == "!multi":
643 | message = message[7:]
644 | commands_set = mp_commands
645 | else:
646 | message = message[1:]
647 | commands_set = commands
648 |
649 | ctx = Context(
650 | author=sender,
651 | reciever=reciever,
652 | cmd=message.split(" ")[0].lower(),
653 | args=message.split(" ")[1:],
654 | )
655 |
656 | for command in commands_set:
657 | if ctx.cmd != command.cmd or not command.perms & ctx.author.privileges:
658 | if ctx.cmd not in command.aliases:
659 | continue
660 |
661 | return await command.trigger(ctx)
662 |
--------------------------------------------------------------------------------
/constants/levels.py:
--------------------------------------------------------------------------------
1 | levels = [
2 | 0,
3 | 30000,
4 | 130000,
5 | 340000,
6 | 700000,
7 | 1250000,
8 | 2030000,
9 | 3080000,
10 | 4440000,
11 | 6150000,
12 | 8250000,
13 | 10780000,
14 | 13780000,
15 | 17290000,
16 | 21350000,
17 | 26000000,
18 | 31280000,
19 | 37230000,
20 | 43890000,
21 | 51300000,
22 | 59500000,
23 | 68530000,
24 | 78430000,
25 | 89240000,
26 | 101000000,
27 | 113750000,
28 | 127530000,
29 | 142380000,
30 | 158340000,
31 | 175450000,
32 | 193750000,
33 | 213280000,
34 | 234080000,
35 | 256190000,
36 | 279650000,
37 | 304500000,
38 | 330780000,
39 | 358530000,
40 | 387790000,
41 | 418600000,
42 | 451000000,
43 | 485030000,
44 | 520730000,
45 | 558140000,
46 | 597300000,
47 | 638250000,
48 | 681030000,
49 | 725680000,
50 | 772240000,
51 | 820750000,
52 | 871250000,
53 | 923780000,
54 | 978380000,
55 | 1035090000,
56 | 1093950000,
57 | 1155000000,
58 | 1218280000,
59 | 1283830000,
60 | 1351690000,
61 | 1421900001,
62 | 1494500002,
63 | 1569530004,
64 | 1647030007,
65 | 1727040013,
66 | 1809600023,
67 | 1894750042,
68 | 1982530076,
69 | 2072980137,
70 | 2166140247,
71 | 2262050446,
72 | 2360750803,
73 | 2462281446,
74 | 2566682602,
75 | 2673994685,
76 | 2784258433,
77 | 2897515179,
78 | 3013807323,
79 | 3133179183,
80 | 3255678529,
81 | 3381359352,
82 | 3510286835,
83 | 3642546303,
84 | 3778259346,
85 | 3917612823,
86 | 4060911082,
87 | 4208669948,
88 | 4361785906,
89 | 4521840632,
90 | 4691649138,
91 | 4876246449,
92 | 5084663609,
93 | 5333124496,
94 | 5650800093,
95 | 6090166168,
96 | 6745647103,
97 | 7787174785,
98 | 9520594613,
99 | 12496396305,
100 | 17705429349,
101 | 26931190827,
102 | 126931190826,
103 | 226931190825,
104 | 326931190824,
105 | 426931190823,
106 | 526931190822,
107 | 626931190821,
108 | 726931190820,
109 | 826931190819,
110 | 926931190818,
111 | 1026931190817,
112 | 1126931190816,
113 | 1226931190815,
114 | 1326931190814,
115 | 1426931190813,
116 | 1526931190812,
117 | 1626931190811,
118 | 1726931190810,
119 | 1826931190809,
120 | 1926931190808,
121 | 2026931190807,
122 | 2126931190806,
123 | 2226931190805,
124 | 2326931190804,
125 | 2426931190803,
126 | 2526931190802,
127 | 2626931190801,
128 | 2726931190800,
129 | 2826931190799,
130 | 2926931190798,
131 | 3026931190797,
132 | 3126931190796,
133 | 3226931190795,
134 | 3326931190794,
135 | 3426931190793,
136 | 3526931190792,
137 | 3626931190791,
138 | 3726931190790,
139 | 3826931190789,
140 | 3926931190788,
141 | 4026931190787,
142 | 4126931190786,
143 | 4226931190785,
144 | 4326931190784,
145 | 4426931190783,
146 | 4526931190782,
147 | 4626931190781,
148 | 4726931190780,
149 | 4826931190779,
150 | 4926931190778,
151 | ]
152 | # this is the required score
153 | # for each level from 0 up to
154 | # 150, since I don't think anyone
155 | # will go above it.
156 |
--------------------------------------------------------------------------------
/constants/match.py:
--------------------------------------------------------------------------------
1 | from enum import IntFlag, unique
2 |
3 |
4 | @unique
5 | class SlotStatus(IntFlag):
6 | OPEN = 1
7 | LOCKED = 2
8 | NOTREADY = 4
9 | READY = 8
10 | NOMAP = 16
11 | PLAYING = 32
12 | COMPLETE = 64
13 | OCCUPIED = NOTREADY | READY | NOMAP | PLAYING | COMPLETE
14 | QUIT = 128
15 |
16 |
17 | @unique
18 | class SlotTeams(IntFlag):
19 | NEUTRAL = 0
20 | BLUE = 1
21 | RED = 2
22 |
23 |
24 | @unique
25 | class TeamType(IntFlag):
26 | HEAD2HEAD = 0
27 | TAG_COOP = 1
28 | TEAM_VS = 2
29 | TAG_TV = 3 # tag team vs
30 |
31 |
32 | @unique
33 | class ScoringType(IntFlag):
34 | SCORE = 0
35 | ACC = 1
36 | COMBO = 2
37 | SCORE_V2 = 3
38 |
39 | @classmethod
40 | def find_value(cls, name: str) -> int:
41 | c = cls(0)
42 |
43 | if name == "sv2":
44 | return c.__class__.SCORE_V2
45 |
46 | if name.upper() in c.__class__.__dict__:
47 | return c.__class__.__dict__[name.upper()]
48 |
--------------------------------------------------------------------------------
/constants/mods.py:
--------------------------------------------------------------------------------
1 | from enum import IntFlag
2 |
3 |
4 | class Mods(IntFlag):
5 | NONE = 0
6 | NOFAIL = 1
7 | EASY = 2
8 | TOUCHDEVICE = 4
9 | HIDDEN = 8
10 | HARDROCK = 16
11 | SUDDENDEATH = 32
12 | DOUBLETIME = 64
13 | RELAX = 128
14 | HALFTIME = 256
15 | NIGHTCORE = 512
16 | FLASHLIGHT = 1024
17 | AUTOPLAY = 2048
18 | SPUNOUT = 4096
19 | RELAX2 = 8192
20 | PERFECT = 16384
21 | KEY4 = 32768
22 | KEY5 = 65536
23 | KEY6 = 131072
24 | KEY7 = 262144
25 | KEY8 = 524288
26 | FADEIN = 1048576
27 | RANDOM = 2097152
28 | CINEMA = 4194304
29 | TARGET = 8388608
30 | KEY9 = 16777216
31 | KEYCOOP = 33554432
32 | KEY1 = 67108864
33 | KEY3 = 134217728
34 | KEY2 = 268435456
35 | SCOREV2 = 536870912
36 | LASTMOD = 1073741824
37 | KEYMOD = KEY1 | KEY2 | KEY3 | KEY4 | KEY5 | KEY6 | KEY7 | KEY8 | KEY9 | KEYCOOP
38 | FREEMODALLOWED = (
39 | NOFAIL
40 | | EASY
41 | | HIDDEN
42 | | HARDROCK
43 | | SUDDENDEATH
44 | | FLASHLIGHT
45 | | FADEIN
46 | | RELAX
47 | | RELAX2
48 | | SPUNOUT
49 | | KEYMOD
50 | )
51 | SCOREINCREASEMODS = HIDDEN | HARDROCK | DOUBLETIME | FLASHLIGHT | FADEIN
52 |
53 | MULTIPLAYER = DOUBLETIME | NIGHTCORE | HALFTIME
54 |
--------------------------------------------------------------------------------
/constants/packets.py:
--------------------------------------------------------------------------------
1 | from enum import unique, IntEnum
2 |
3 |
4 | @unique
5 | class BanchoPackets(IntEnum):
6 | OSU_CHANGE_ACTION = 0
7 | OSU_SEND_PUBLIC_MESSAGE = 1
8 | OSU_LOGOUT = 2
9 | OSU_REQUEST_STATUS_UPDATE = 3
10 | OSU_PING = 4
11 | CHO_USER_ID = 5
12 | CHO_CMD_ERR = 6 # Unused
13 | CHO_SEND_MESSAGE = 7
14 | CHO_PONG = 8
15 | CHO_HANDLE_IRC_CHANGE_USERNAME = 9
16 | CHO_HANDLE_IRC_QUIT = 10 # Unused
17 | CHO_USER_STATS = 11
18 | CHO_USER_LOGOUT = 12
19 | CHO_SPECTATOR_JOINED = 13
20 | CHO_SPECTATOR_LEFT = 14
21 | CHO_SPECTATE_FRAMES = 15
22 | OSU_START_SPECTATING = 16
23 | OSU_STOP_SPECTATING = 17
24 | OSU_SPECTATE_FRAMES = 18
25 | CHO_VERSION_UPDATE = 19
26 | OSU_ERROR_REPORT = 20 # Unused
27 | OSU_CANT_SPECTATE = 21
28 | CHO_SPECTATOR_CANT_SPECTATE = 22
29 | CHO_GET_ATTENTION = 23
30 | CHO_NOTIFICATION = 24
31 | OSU_SEND_PRIVATE_MESSAGE = 25
32 | CHO_UPDATE_MATCH = 26
33 | CHO_NEW_MATCH = 27
34 | CHO_DISPOSE_MATCH = 28
35 | OSU_PART_LOBBY = 29
36 | OSU_JOIN_LOBBY = 30
37 | OSU_CREATE_MATCH = 31
38 | OSU_JOIN_MATCH = 32
39 | OSU_PART_MATCH = 33
40 | CHO_TOGGLE_BLOCK_NON_FRIEND_DMS = 34
41 | CHO_MATCH_JOIN_SUCCESS = 36
42 | CHO_MATCH_JOIN_FAIL = 37
43 | OSU_MATCH_CHANGE_SLOT = 38
44 | OSU_MATCH_READY = 39
45 | OSU_MATCH_LOCK = 40
46 | OSU_MATCH_CHANGE_SETTINGS = 41
47 | CHO_FELLOW_SPECTATOR_JOINED = 42
48 | CHO_FELLOW_SPECTATOR_LEFT = 43
49 | OSU_MATCH_START = 44
50 | CHO_ALL_PLAYERS_LOADED = 45
51 | CHO_MATCH_START = 46
52 | OSU_MATCH_SCORE_UPDATE = 47
53 | CHO_MATCH_SCORE_UPDATE = 48
54 | OSU_MATCH_COMPLETE = 49
55 | CHO_MATCH_TRANSFER_HOST = 50
56 | OSU_MATCH_CHANGE_MODS = 51
57 | OSU_MATCH_LOAD_COMPLETE = 52
58 | CHO_MATCH_ALL_PLAYERS_LOADED = 53
59 | OSU_MATCH_NO_BEATMAP = 54
60 | OSU_MATCH_NOT_READY = 55
61 | OSU_MATCH_FAILED = 56
62 | CHO_MATCH_PLAYER_FAILED = 57
63 | CHO_MATCH_COMPLETE = 58
64 | OSU_MATCH_HAS_BEATMAP = 59
65 | OSU_MATCH_SKIP_REQUEST = 60
66 | CHO_MATCH_SKIP = 61
67 | CHO_UNAUTHORIZED = 62 # Unused
68 | OSU_CHANNEL_JOIN = 63
69 | CHO_CHANNEL_JOIN_SUCCESS = 64
70 | CHO_CHANNEL_INFO = 65
71 | CHO_CHANNEL_KICK = 66
72 | CHO_CHANNEL_AUTO_JOIN = 67
73 | OSU_BEATMAP_INFO_REQUEST = 68
74 | CHO_BEATMAP_INFO_REPLY = 69
75 | OSU_MATCH_TRANSFER_HOST = 70
76 | CHO_PRIVILEGES = 71
77 | CHO_FRIENDS_LIST = 72
78 | OSU_FRIEND_ADD = 73
79 | OSU_FRIEND_REMOVE = 74
80 | CHO_PROTOCOL_VERSION = 75
81 | CHO_MAIN_MENU_ICON = 76
82 | OSU_MATCH_CHANGE_TEAM = 77
83 | OSU_CHANNEL_PART = 78
84 | OSU_RECEIVE_UPDATES = 79
85 | CHO_MONITOR = 80 # unused
86 | CHO_MATCH_PLAYER_SKIPPED = 81
87 | OSU_SET_AWAY_MESSAGE = 82
88 | CHO_USER_PRESENCE = 83
89 | OSU_IRC_ONLY = 84
90 | OSU_USER_STATS_REQUEST = 85
91 | CHO_RESTART = 86
92 | OSU_MATCH_INVITE = 87
93 | CHO_MATCH_INVITE = 88
94 | CHO_CHANNEL_INFO_END = 89
95 | OSU_MATCH_CHANGE_PASSWORD = 90
96 | CHO_MATCH_CHANGE_PASSWORD = 91
97 | CHO_SILENCE_END = 92
98 | OSU_TOURNAMENT_MATCH_INFO_REQUEST = 93
99 | CHO_USER_SILENCED = 94
100 | CHO_USER_PRESENCE_SINGLE = 95
101 | CHO_USER_PRESENCE_BUNDLE = 96
102 | OSU_USER_PRESENCE_REQUEST = 97
103 | OSU_USER_PRESENCE_REQUEST_ALL = 98
104 | OSU_TOGGLE_BLOCK_NON_FRIEND_DMS = 99
105 | CHO_USER_DM_BLOCKED = 100
106 | CHO_TARGET_IS_SILENCED = 101
107 | CHO_VERSION_UPDATE_FORCED = 102
108 | CHO_SWITCH_SERVER = 103
109 | CHO_ACCOUNT_RESTRICTED = 104
110 | CHO_RTX = 105 # depricated
111 | CHO_MATCH_ABORT = 106
112 | CHO_SWITCH_TOURNAMENT_SERVER = 107
113 | OSU_TOURNAMENT_JOIN_MATCH_CHANNEL = 108
114 | OSU_TOURNAMENT_LEAVE_MATCH_CHANNEL = 109
115 |
--------------------------------------------------------------------------------
/constants/player.py:
--------------------------------------------------------------------------------
1 | from enum import IntEnum, IntFlag, unique
2 |
3 |
4 | @unique
5 | class Ranks(IntFlag):
6 | NONE = 0
7 | NORMAL = 1
8 | BAT = 2
9 | SUPPORTER = 4
10 | FRIEND = 8
11 | PEPPY = 16
12 | TOURNAMENT = 32
13 |
14 |
15 | @unique
16 | class PresenceFilter(IntEnum):
17 | NIL = 0
18 | ALL = 1
19 | FRIENDS = 2
20 |
21 |
22 | @unique
23 | class Privileges(IntEnum):
24 | BANNED = 1 << 0
25 |
26 | USER = 1 << 1
27 | VERIFIED = 1 << 2
28 |
29 | SUPPORTER = 1 << 3
30 |
31 | BAT = 1 << 4
32 | MODERATOR = 1 << 5
33 | ADMIN = 1 << 6
34 | DEV = 1 << 7
35 |
36 | PENDING = 1 << 8
37 |
38 |
39 | @unique
40 | class bStatus(IntEnum):
41 | IDLE = 0
42 | AFK = 1
43 | PLAYING = 2
44 | EDITING = 3
45 | MODDING = 4
46 | MULTIPLAYER = 5
47 | WATCHING = 6
48 | UNKNOWN = 7
49 | TESTING = 8
50 | SUBMITTING = 9
51 | PAUSED = 10
52 | LOBBY = 11
53 | MULTIPLAYING = 12
54 | OSUDIRECT = 13
55 |
56 |
57 | country_codes = {
58 | "XX": 0, # No/Unknown Country - Doesn't know where is it
59 | "AP": 1, # Oceania
60 | "EU": 2, # Europe
61 | "AD": 3, # Andorra
62 | "AE": 4, # UAE - United Arab Emirates
63 | "AF": 5, # Afghanistan
64 | "AG": 6, # Antigua
65 | "AI": 7, # Anguilla
66 | "AL": 8, # Albania
67 | "AM": 9, # Armenia
68 | "AN": 10, # Netherlands Antilles
69 | "AO": 11, # Angola
70 | "AQ": 12, # Antarctica
71 | "AR": 13, # Argentina
72 | "AS": 14, # American Samoa
73 | "AT": 15, # Austria
74 | "AU": 16, # Australia
75 | "AW": 17, # Aruba
76 | "AZ": 18, # Azerbaijan
77 | "BA": 19, # Bosnia
78 | "BB": 20, # Barbados
79 | "BD": 21, # Bangladesh
80 | "BE": 22, # Belgium
81 | "BF": 23, # Burkina Faso
82 | "BG": 24, # Bulgaria
83 | "BH": 25, # Bahrain
84 | "BI": 26, # Burundi
85 | "BJ": 27, # Benin
86 | "BM": 28, # Bermuda
87 | "BN": 29, # Brunei Darussalam
88 | "BO": 30, # Bolivia
89 | "BR": 31, # Brazil
90 | "BS": 32, # Bahamas
91 | "BT": 33, # Bhutan
92 | "BV": 34, # Bouvet Island
93 | "BW": 35, # Botswana
94 | "BY": 36, # Belarus
95 | "BZ": 37, # Belize
96 | "CA": 38, # Canada
97 | "CC": 39, # Cocos Islands
98 | "CD": 40, # Congo
99 | "CF": 41, # Central African Republic
100 | "CG": 42, # Congo, Democratic Republic of
101 | "CH": 43, # Switzerland
102 | "CI": 44, # Cote D'Ivoire
103 | "CK": 45, # Cook Islands
104 | "CL": 46, # Chile
105 | "CM": 47, # Cameroon
106 | "CN": 48, # China
107 | "CO": 49, # Colombia
108 | "CR": 50, # Costa Rica
109 | "CU": 51, # Cuba
110 | "CV": 52, # Cape Verd
111 | "CX": 53, # Christmas Island
112 | "CY": 54, # Cyprus
113 | "CZ": 55, # Czech Republic
114 | "DE": 56, # Germany
115 | "DJ": 57, # Djibouti
116 | "DK": 58, # Denmark
117 | "DM": 59, # Dominica
118 | "DO": 60, # Dominican Republic
119 | "DZ": 61, # Algeria
120 | "EC": 62, # Ecuador
121 | "EE": 63, # Estonia
122 | "EG": 64, # Egypt
123 | "EH": 65, # Western Sahara
124 | "ER": 66, # Eritrea
125 | "ES": 67, # Spain
126 | "ET": 68, # Ethiopia
127 | "FI": 69, # Finland
128 | "FJ": 70, # Fiji
129 | "FK": 71, # Falkland Islands
130 | "FM": 72, # Micronesia, Federated States of
131 | "FO": 73, # Faroe Islands
132 | "FR": 74, # France
133 | "FX": 75, # France, Metropolitan
134 | "GA": 76, # Gabon
135 | "GB": 77, # United Kingdom
136 | "GD": 78, # Grenada
137 | "GE": 79, # Georgia
138 | "GF": 80, # French Guiana
139 | "GH": 81, # Ghana
140 | "GI": 82, # Gibraltar
141 | "GL": 83, # Greenland
142 | "GM": 84, # Gambia
143 | "GN": 85, # Guinea
144 | "GP": 86, # Guadeloupe
145 | "GQ": 87, # Equatorial Guinea
146 | "GR": 88, # Greece
147 | "GS": 89, # South Georgia
148 | "GT": 90, # Guatemala
149 | "GU": 91, # Guam
150 | "GW": 92, # Guinea-Bissau
151 | "GY": 93, # Guyana
152 | "HK": 94, # Hong Kong
153 | "HM": 95, # Heard Island
154 | "HN": 96, # Honduras
155 | "HR": 97, # Croatia
156 | "HT": 98, # Haiti
157 | "HU": 99, # Hungary
158 | "ID": 100, # Indonesia
159 | "IE": 101, # Ireland
160 | "IL": 102, # Israel
161 | "IN": 103, # India
162 | "IO": 104, # British Indian Ocean Territory
163 | "IQ": 105, # Iraq
164 | "IR": 106, # Iran, Islamic Republic of
165 | "IS": 107, # Iceland
166 | "IT": 108, # Italy
167 | "JM": 109, # Jamaica
168 | "JO": 110, # Jordan
169 | "JP": 111, # Japan
170 | "KE": 112, # Kenya
171 | "KG": 113, # Kyrgyzstan
172 | "KH": 114, # Cambodia
173 | "KI": 115, # Kiribati
174 | "KM": 116, # Comoros
175 | "KN": 117, # St. Kitts and Nevis
176 | "KP": 118, # Korea, Democratic People's Republic of
177 | "KR": 119, # Korea
178 | "KW": 120, # Kuwait
179 | "KY": 121, # Cayman Islands
180 | "KZ": 122, # Kazakhstan
181 | "LA": 123, # Lao
182 | "LB": 124, # Lebanon
183 | "LC": 125, # St. Lucia
184 | "LI": 126, # Liechtenstein
185 | "LK": 127, # Sri Lanka
186 | "LR": 128, # Liberia
187 | "LS": 129, # Lesotho
188 | "LT": 130, # Lithuania
189 | "LU": 131, # Luxembourg
190 | "LV": 132, # Latvia
191 | "LY": 133, # Libyan Arab Jamahiriya
192 | "MA": 134, # Morocco
193 | "MC": 135, # Monaco
194 | "MD": 136, # Moldova, Republic of
195 | "MG": 137, # Madagascar
196 | "MH": 138, # Marshall Islands
197 | "MK": 139, # Macedonia, the Former Yugoslav Republic of
198 | "ML": 140, # Mali
199 | "MM": 141, # Myanmar
200 | "MN": 142, # Mongolia
201 | "MO": 143, # Macau
202 | "MP": 144, # Northern Mariana Islands
203 | "MQ": 145, # Martinique
204 | "MR": 146, # Mauritania
205 | "MS": 147, # Montserrat
206 | "MT": 148, # Malta
207 | "MU": 149, # Mauritius
208 | "MV": 150, # Maldives
209 | "MW": 151, # Malawi
210 | "MX": 152, # Mexico
211 | "MY": 153, # Malaysia
212 | "MZ": 154, # Mozambique
213 | "NA": 155, # Namibia
214 | "NC": 156, # New Caledonia
215 | "NE": 157, # Niger
216 | "NF": 158, # Norfolk Island
217 | "NG": 159, # Nigeria
218 | "NI": 160, # Nicaragua
219 | "NL": 161, # Netherlands
220 | "NO": 162, # Norway
221 | "NP": 163, # Nepal
222 | "NR": 164, # Nauru
223 | "NU": 165, # Niue
224 | "NZ": 166, # New Zealand
225 | "OM": 167, # Oman
226 | "PA": 168, # Panama
227 | "PE": 169, # Peru
228 | "PF": 170, # French Polynesia
229 | "PG": 171, # Papua New Guinea
230 | "PH": 172, # Philippines
231 | "PK": 173, # Pakistan
232 | "PL": 174, # Poland
233 | "PM": 175, # St. Pierre
234 | "PN": 176, # Pitcairn
235 | "PR": 177, # Puerto Rico
236 | "PS": 178, # Palestinian Territory
237 | "PT": 179, # Portugal
238 | "PW": 180, # Palau
239 | "PY": 181, # Paraguay
240 | "QA": 182, # Qatar
241 | "RE": 183, # Reunion
242 | "RO": 184, # Romania
243 | "RU": 185, # Russian Federation
244 | "RW": 186, # Rwanda
245 | "SA": 187, # Saudi Arabia
246 | "SB": 188, # Solomon Islands
247 | "SC": 189, # Seychelles
248 | "SD": 190, # Sudan
249 | "SE": 191, # Sweden
250 | "SG": 192, # Singapore
251 | "SH": 193, # St. Helena
252 | "SI": 194, # Slovenia
253 | "SJ": 195, # Svalbard and Jan Mayen
254 | "SK": 196, # Slovakia
255 | "SL": 197, # Sierra Leone
256 | "SM": 198, # San Marino
257 | "SN": 199, # Senegal
258 | "SO": 200, # Somalia
259 | "SR": 201, # Suriname
260 | "ST": 202, # Sao Tome and Principe
261 | "SV": 203, # El Salvador
262 | "SY": 204, # Syrian Arab Republic
263 | "SZ": 205, # Swaziland
264 | "TC": 206, # Turks and Caicos Islands
265 | "TD": 207, # Chad
266 | "TF": 208, # French Southern Territories
267 | "TG": 209, # Togo
268 | "TH": 210, # Thailand
269 | "TJ": 211, # Tajikistan
270 | "TK": 212, # Tokelau
271 | "TM": 213, # Turkmenistan
272 | "TN": 214, # Tunisia
273 | "TO": 215, # Tonga
274 | "TL": 216, # Timor-Leste
275 | "TR": 217, # Turkey
276 | "TT": 218, # Trinidad and Tobago
277 | "TV": 219, # Tuvalu
278 | "TW": 220, # Taiwan
279 | "TZ": 221, # Tanzania
280 | "UA": 222, # Ukraine
281 | "UG": 223, # Uganda
282 | "UM": 224, # US (Island)
283 | "US": 225, # United States
284 | "UY": 226, # Uruguay
285 | "UZ": 227, # Uzbekistan
286 | "VA": 228, # Holy See
287 | "VC": 229, # St. Vincent
288 | "VE": 230, # Venezuela
289 | "VG": 231, # Virgin Islands, British
290 | "VI": 232, # Virgin Islands, U.S.
291 | "VN": 233, # Vietnam
292 | "VU": 234, # Vanuatu
293 | "WF": 235, # Wallis and Futuna
294 | "WS": 236, # Samoa
295 | "YE": 237, # Yemen
296 | "YT": 238, # Mayotte
297 | "RS": 239, # Serbia
298 | "ZA": 240, # South Africa
299 | "ZM": 241, # Zambia
300 | "ME": 242, # Montenegro
301 | "ZW": 243, # Zimbabwe
302 | "A1": 244, # Unknown - Anonymous Proxy
303 | "A2": 245, # Satellite Provider
304 | "O1": 246, # Other
305 | "AX": 247, # Aland Islands
306 | "GG": 248, # Guernsey
307 | "IM": 249, # Isle of Man
308 | "JE": 250, # Jersey
309 | "BL": 251, # St. Barthelemy
310 | "MF": 252, # Saint Martin
311 | }
312 |
--------------------------------------------------------------------------------
/constants/playmode.py:
--------------------------------------------------------------------------------
1 | from enum import unique, IntEnum
2 |
3 |
4 | @unique
5 | class Mode(IntEnum):
6 | OSU = 0
7 | TAIKO = 1
8 | CATCH = 2
9 | MANIA = 3
10 |
--------------------------------------------------------------------------------
/decorators.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 | from objects import glob
3 |
4 |
5 | def register_task() -> Callable:
6 | def wrapper(cb: Callable) -> None:
7 | glob.registered_tasks.append({"func": cb})
8 |
9 | return wrapper
10 |
--------------------------------------------------------------------------------
/events/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osumitsuha/Ragnarok/948214e20d4669ad1fe22416552665174f70363c/events/__init__.py
--------------------------------------------------------------------------------
/events/avatar.py:
--------------------------------------------------------------------------------
1 | from lenhttp import Request, Router
2 | from objects import glob
3 | import aiofiles
4 | import os
5 |
6 | avatar = Router({f"a.{glob.domain}", f"127.0.0.1:{glob.port}"})
7 | a_path = ".data/avatars/"
8 |
9 | @avatar.add_endpoint("/")
10 | async def handle(req: Request, uid: str) -> bytes:
11 | if (
12 | not uid or
13 | not uid.isnumeric()
14 | ):
15 | return 0
16 |
17 | if not os.path.exists(a_path + f"{uid}.png"):
18 | async with aiofiles.open(a_path + "0.png", "rb") as ava:
19 | avatar = await ava.read()
20 | else:
21 | async with aiofiles.open(a_path + f"{uid}.png", "rb") as ava:
22 | avatar = await ava.read()
23 |
24 | req.add_header("Content-Type", "image/png")
25 |
26 | return avatar
27 |
--------------------------------------------------------------------------------
/events/bancho.py:
--------------------------------------------------------------------------------
1 | from constants.player import bStatus, Privileges
2 | from constants.packets import BanchoPackets
3 | from packets.reader import Reader, Packet
4 | from constants import commands as cmd
5 | from objects.beatmap import Beatmap
6 | from constants.playmode import Mode
7 | from lenhttp import Router, Request
8 | from objects.player import Player
9 | from constants.mods import Mods
10 | from constants.match import *
11 | from typing import Callable
12 | from packets import writer
13 | from utils import general
14 | from objects import glob
15 | from utils import score
16 | from utils import log
17 | from oppai import *
18 | import asyncio
19 | import bcrypt
20 | import struct
21 | import time
22 | import copy
23 | import os
24 | import re
25 |
26 |
27 | def register_event(packet: BanchoPackets, restricted: bool = False) -> Callable:
28 | def decorator(cb: Callable) -> Callable:
29 | glob.packets |= {
30 | packet.value: Packet(packet=packet, callback=cb, restricted=restricted)
31 | }
32 |
33 | return decorator
34 |
35 | bancho = Router({re.compile(rf"^c[e4-6]?\.{glob.domain}"), f"127.0.0.1:{glob.port}"})
36 | IGNORED_PACKETS: list[int] = [4, 79]
37 |
38 |
39 | @bancho.add_endpoint("/", methods=["POST"])
40 | async def handle_bancho(req: Request):
41 | if not "User-Agent" in req.headers.keys() or req.headers["User-Agent"] != "osu!":
42 | return "no"
43 |
44 | if not "osu-token" in req.headers:
45 | return await login(req)
46 |
47 | token = req.headers["osu-token"]
48 |
49 | if not (player := glob.players.get_user(token)):
50 | return (
51 | await writer.Notification("Server has restarted")
52 | + await writer.ServerRestart()
53 | )
54 |
55 | for p in (sr := Reader(req.body)):
56 | if player.is_restricted and (not p.restricted):
57 | continue
58 |
59 | start = time.time_ns()
60 |
61 | await p.callback(player, sr)
62 |
63 | end = (time.time_ns() - start) / 1e6
64 |
65 | if glob.debug and p.packet.value not in IGNORED_PACKETS:
66 | log.debug(
67 | f"Packet <{p.packet.value} | {p.packet.name}> has been requested by {player.username} - {round(end, 2)}ms"
68 | )
69 |
70 | req.add_header("Content-Type", "text/html; charset=UTF-8")
71 | player.last_update = time.time()
72 |
73 | return player.dequeue() or b""
74 |
75 |
76 | async def login(req: Request) -> bytes:
77 | req.add_header("cho-token", "no")
78 |
79 | start = time.time_ns()
80 | data = bytearray(await writer.ProtocolVersion(19))
81 | # parse login info and client info.
82 | # {0}
83 | login_info = req.body.decode().split("\n")[:-1]
84 |
85 | # {0}|{1}|{2}|{3}|{4}
86 | # 0 = Build name, 1 = Time offset
87 | # 2 = Display city location, 3 = Client hash
88 | # 4 = Block nonfriend PMs
89 | client_info = login_info[2].split("|")
90 |
91 | # the players ip address
92 | ip = req.headers["X-Real-IP"]
93 |
94 | # get all user needed information
95 | if not (
96 | user_info := await glob.sql.fetch(
97 | "SELECT username, id, privileges, "
98 | "passhash, lon, lat, country, cc FROM users "
99 | "WHERE safe_username = %s",
100 | [login_info[0].lower().replace(" ", "_")],
101 | )
102 | ):
103 | return await writer.UserID(-1)
104 |
105 | # encode user password and input password.
106 | phash = user_info["passhash"].encode("utf-8")
107 | pmd5 = login_info[1].encode("utf-8")
108 |
109 | # check if the password is correct
110 | if phash in glob.bcrypt_cache:
111 | if pmd5 != glob.bcrypt_cache[phash]:
112 | log.warn(
113 | f"USER {user_info['username']} ({user_info['id']}) | Login fail. (WRONG PASSWORD)"
114 | )
115 |
116 | return await writer.UserID(-1)
117 | else:
118 | if not bcrypt.checkpw(pmd5, phash):
119 | log.warn(
120 | f"USER {user_info['username']} ({user_info['id']}) | Login fail. (WRONG PASSWORD)"
121 | )
122 |
123 | return await writer.UserID(-1)
124 |
125 | glob.bcrypt_cache[phash] = pmd5
126 |
127 | if glob.players.get_user(user_info["username"]):
128 | # user is already online? sus
129 | return await writer.Notification(
130 | "You're already online on the server!"
131 | ) + await writer.UserID(-1)
132 |
133 | # invalid security hash (old ver probably using that)
134 | if len(client_info[3].split(":")) < 4:
135 | return await writer.UserID(-2)
136 |
137 | # check if user is restricted; pretty sure its like this lol
138 | if not user_info["privileges"] & Privileges.VERIFIED | Privileges.PENDING:
139 | data += await writer.Notification(
140 | "Your account has been set in restricted mode."
141 | )
142 |
143 | # only allow 2021 clients
144 | # if not client_info[0].startswith("b2021"):
145 | # return await writer.UserID(-2)
146 |
147 | # check if the user is banned.
148 | if user_info["privileges"] & Privileges.BANNED:
149 | log.info(
150 | f"{user_info['username']} tried to login, but failed to do so, since they're banned."
151 | )
152 |
153 | return await writer.UserID(-3)
154 |
155 | # TODO: Hardware ban check (security[3] and [4])
156 | """
157 | if (UserManager.CheckBannedHardwareId(securityHashParts[3], securityHashParts[4]))
158 | {
159 | SendRequest(RequestType.Bancho_LoginReply, new bInt(-5));
160 | return false;
161 | }
162 | """
163 | # if my_balls > sussy_balls:
164 | # return BanchoResponse(await writer.UserID(-5))
165 |
166 | kwargs = {
167 | "block_nonfriend": client_info[4],
168 | "version": client_info[0],
169 | "time_offset": int(client_info[1]),
170 | "ip": ip,
171 | }
172 |
173 | p = Player(**user_info, **kwargs)
174 |
175 | p.last_update = time.time()
176 |
177 | glob.players.add_user(p)
178 |
179 | await asyncio.gather(*[p.get_friends(), p.update_stats_cache()])
180 |
181 | if p.privileges & Privileges.PENDING:
182 | await glob.bot.send_message("Since we're still in beta, you'll need to verify your account with a beta key given by one of the founders. You'll have 30 minutes to verify the account, or the account will be deleted. To verify your account, please enter !key ", reciever=p)
183 |
184 | if (
185 | not (user_info["lon"] or user_info["lat"] or user_info["cc"])
186 | or user_info["country"] == "XX"
187 | ):
188 | await p.set_location()
189 | await p.save_location()
190 |
191 | asyncio.create_task(p.check_loc())
192 |
193 | data += await writer.UserID(p.id)
194 | data += await writer.UserPriv(p.privileges)
195 | data += await writer.MainMenuIcon()
196 | data += await writer.FriendsList(*p.friends)
197 | data += await writer.UserPresence(p)
198 | data += await writer.UpdateStats(p)
199 |
200 | for chan in glob.channels.channels:
201 | if chan.public:
202 | data += await writer.ChanInfo(chan.name)
203 |
204 | if chan.auto_join:
205 | data += await writer.ChanAutoJoin(chan.name)
206 | await p.join_channel(chan)
207 |
208 | if chan.staff and p.is_staff:
209 | data += await writer.ChanInfo(chan.name)
210 | data += await writer.ChanJoin(chan.name)
211 | await p.join_channel(chan)
212 |
213 |
214 | for player in glob.players.players:
215 | if player != p:
216 | player.enqueue(await writer.UserPresence(p) + await writer.UpdateStats(p))
217 |
218 | data += await writer.UserPresence(player)
219 | data += await writer.UpdateStats(player)
220 |
221 |
222 | data += await writer.ChanInfoEnd()
223 |
224 | et = (time.time_ns() - start) / 1e6
225 |
226 | data += await writer.Notification(
227 | "Welcome to Ragnarok!\n"
228 | "made by Aoba and Simon.\n"
229 | "\n"
230 | "Authorization took " + str(general.rag_round(et, 2)) + "ms."
231 | )
232 |
233 | log.info(f"<{user_info['username']} | {user_info['id']}; {p.token}> logged in.")
234 |
235 | req.add_header("cho-token", p.token)
236 |
237 | return data
238 |
239 |
240 | # id: 0
241 | @register_event(BanchoPackets.OSU_CHANGE_ACTION, restricted=True)
242 | async def change_action(p: Player, sr: Reader) -> None:
243 | p.status = bStatus(sr.read_byte())
244 | p.status_text = sr.read_str()
245 | p.beatmap_md5 = sr.read_str()
246 | p.current_mods = sr.read_uint32()
247 | p.play_mode = sr.read_byte()
248 | p.beatmap_id = sr.read_int32()
249 |
250 | p.relax = int(bool(p.current_mods & Mods.RELAX))
251 | asyncio.create_task(p.update_stats_cache())
252 |
253 | if not p.is_restricted:
254 | glob.players.enqueue(await writer.UpdateStats(p))
255 |
256 |
257 | # id: 1
258 | @register_event(BanchoPackets.OSU_SEND_PUBLIC_MESSAGE)
259 | async def send_public_message(p: Player, sr: Reader) -> None:
260 | # sender; but unused since
261 | # we know who sent it lol
262 | sr.read_str()
263 |
264 | msg = sr.read_str()
265 | chan_name = sr.read_str()
266 |
267 | sr.read_int32() # sender id
268 |
269 | if p.privileges & Privileges.PENDING:
270 | return
271 |
272 | if not msg or msg.isspace():
273 | return
274 |
275 | if chan_name == "#multiplayer":
276 | if not (m := p.match):
277 | return
278 |
279 | chan = m.chat
280 | elif chan_name == "#spectator":
281 | # im not sure how to handle this
282 | chan = None
283 | else:
284 | chan = glob.channels.get_channel(chan_name)
285 |
286 | if not chan:
287 | await p.shout(
288 | "You can't send messages to a channel, you're not already connected to."
289 | )
290 | return
291 |
292 | if np := glob.regex["np"].search(msg):
293 | log.info(np.groups())
294 | p.last_np = await Beatmap._get_beatmap_from_sql(None, np.groups(0))
295 |
296 | await chan.send(msg, p)
297 |
298 | if msg[0] == glob.prefix:
299 | if resp := await cmd.handle_commands(message=msg, sender=p, reciever=chan):
300 | await chan.send(resp, sender=glob.bot)
301 |
302 |
303 | # id: 2
304 | @register_event(BanchoPackets.OSU_LOGOUT, restricted=True)
305 | async def logout(p: Player, sr: Reader) -> None:
306 | reason = sr.read_int32() # 1 means update
307 |
308 | if (time.time() - p.login_time) < 1:
309 | return
310 |
311 | log.info(f"{p.username} logged out.")
312 |
313 | await p.logout()
314 |
315 |
316 | # id: 3
317 | @register_event(BanchoPackets.OSU_REQUEST_STATUS_UPDATE, restricted=True)
318 | async def update_stats(p: Player, sr: Reader) -> None:
319 | # TODO: add this update for spectator as well
320 | # since they need to have up-to-date beatmap info
321 | p.enqueue(await writer.UpdateStats(p))
322 |
323 |
324 | # id: 4
325 | @register_event(BanchoPackets.OSU_PING, restricted=True)
326 | async def pong(p: Player, sr: Reader) -> None:
327 | pass
328 |
329 |
330 | # id: 16
331 | @register_event(BanchoPackets.OSU_START_SPECTATING)
332 | async def start_spectate(p: Player, sr: Reader) -> None:
333 | spec = sr.read_int32()
334 |
335 | if p.privileges & Privileges.PENDING:
336 | return
337 |
338 | if not (host := glob.players.get_user(spec)):
339 | return
340 |
341 | await host.add_spectator(p)
342 |
343 |
344 | # id: 17
345 | @register_event(BanchoPackets.OSU_STOP_SPECTATING)
346 | async def stop_spectate(p: Player, sr: Reader) -> None:
347 | host = p.spectating
348 |
349 | if p.privileges & Privileges.PENDING:
350 | return
351 |
352 | if not host:
353 | return
354 |
355 | await host.remove_spectator(p)
356 |
357 |
358 | # id: 18
359 | @register_event(BanchoPackets.OSU_SPECTATE_FRAMES)
360 | async def spectating_frames(p: Player, sr: Reader) -> None:
361 | # TODO: make a proper R/W instead of echoing like this
362 | sframe = sr.read_raw()
363 |
364 | # packing manually seems to be faster, so let's use that.
365 | data = struct.pack(" None:
377 | host = p.spectating
378 |
379 | id = sr.read_int32()
380 |
381 | if not host:
382 | return
383 |
384 | if p.privileges & Privileges.PENDING:
385 | return
386 |
387 | ret = await writer.UsrCantSpec(id)
388 |
389 | host.enqueue(ret)
390 |
391 | for t in host.spectators:
392 | t.enqueue(ret)
393 |
394 |
395 | # id: 25
396 | @register_event(BanchoPackets.OSU_SEND_PRIVATE_MESSAGE)
397 | async def send_private_message(p: Player, sr: Reader) -> None:
398 | # sender - but unused, since we already know
399 | # who the sender is lol
400 | sr.read_str()
401 |
402 | msg = sr.read_str()
403 | recieverr = sr.read_str()
404 |
405 | sr.read_int32() # sender id
406 |
407 | if not (reciever := glob.players.get_user(recieverr)):
408 | await p.shout("The player you're trying to reach is currently offline.")
409 | return
410 |
411 | if not reciever.bot:
412 | await p.send_message(msg, reciever=reciever)
413 | else:
414 | if np := glob.regex["np"].search(msg):
415 | p.last_np = await Beatmap.get_beatmap(beatmap_id=np.groups(1)[0])
416 |
417 | if msg[0] == glob.prefix:
418 | if resp := await cmd.handle_commands(
419 | message=msg, sender=p, reciever=glob.bot
420 | ):
421 | await glob.bot.send_message(resp, reciever=p)
422 | return
423 |
424 | await glob.bot.send_message("beep boop", reciever=p)
425 |
426 |
427 | # id: 29
428 | @register_event(BanchoPackets.OSU_PART_LOBBY)
429 | async def lobby_part(p: Player, sr: Reader) -> None:
430 | p.in_lobby = False
431 |
432 |
433 | # id: 30
434 | @register_event(BanchoPackets.OSU_JOIN_LOBBY)
435 | async def lobby_join(p: Player, sr: Reader) -> None:
436 | p.in_lobby = True
437 |
438 | if p.privileges & Privileges.PENDING:
439 | return
440 |
441 | if p.match:
442 | await p.leave_match()
443 |
444 | for match in glob.matches.matches:
445 | if match.connected:
446 | p.enqueue(await writer.Match(match))
447 |
448 |
449 | # id: 31
450 | @register_event(BanchoPackets.OSU_CREATE_MATCH)
451 | async def mp_create_match(p: Player, sr: Reader) -> None:
452 | m = sr.read_match()
453 |
454 | await glob.matches.add_match(m)
455 |
456 | await p.join_match(m, pwd=m.match_pass)
457 |
458 |
459 | # id: 32
460 | @register_event(BanchoPackets.OSU_JOIN_MATCH)
461 | async def mp_join(p: Player, sr: Reader) -> None:
462 | matchid = sr.read_int32()
463 | matchpass = sr.read_str()
464 |
465 | if (
466 | p.match or
467 | not (m := await glob.matches.find_match(matchid))
468 | ):
469 | p.enqueue(await writer.MatchFail())
470 | return
471 |
472 | await p.join_match(m, pwd=matchpass)
473 |
474 |
475 | # id: 33
476 | @register_event(BanchoPackets.OSU_PART_MATCH)
477 | async def mp_leave(p: Player, sr: Reader) -> None:
478 | if p.match:
479 | await p.leave_match()
480 |
481 |
482 | # id: 38
483 | @register_event(BanchoPackets.OSU_MATCH_CHANGE_SLOT)
484 | async def mp_change_slot(p: Player, sr: Reader) -> None:
485 | slot_id = sr.read_int32()
486 |
487 | if (
488 | not (m := p.match) or
489 | m.in_progress
490 | ):
491 | return
492 |
493 | slot = m.slots[slot_id]
494 |
495 | if slot.status == SlotStatus.OCCUPIED:
496 | log.error(f"{p.username} tried to change to an occupied slot ({m!r})")
497 | return
498 |
499 | if not (old_slot := m.find_user(p)):
500 | return
501 |
502 | slot.copy_from(old_slot)
503 |
504 | old_slot.reset()
505 |
506 | await m.enqueue_state()
507 |
508 |
509 | # id: 39
510 | @register_event(BanchoPackets.OSU_MATCH_READY)
511 | async def mp_ready_up(p: Player, sr: Reader) -> None:
512 | if (
513 | not (m := p.match) or
514 | m.in_progress
515 | ):
516 | return
517 |
518 | slot = m.find_user(p)
519 |
520 | if slot.status == SlotStatus.READY:
521 | return
522 |
523 | slot.status = SlotStatus.READY
524 |
525 | await m.enqueue_state()
526 |
527 |
528 | # id: 40
529 | @register_event(BanchoPackets.OSU_MATCH_LOCK)
530 | async def mp_lock_slot(p: Player, sr: Reader) -> None:
531 | slot_id = sr.read_int32()
532 |
533 | if (
534 | not (m := p.match) or
535 | m.in_progress
536 | ):
537 | return
538 |
539 | slot = m.slots[slot_id]
540 |
541 | if slot.status == SlotStatus.LOCKED:
542 | slot.status = SlotStatus.OPEN
543 | else:
544 | slot.status = SlotStatus.LOCKED
545 |
546 | await m.enqueue_state()
547 |
548 |
549 | # id: 41
550 | @register_event(BanchoPackets.OSU_MATCH_CHANGE_SETTINGS)
551 | async def mp_change_settings(p: Player, sr: Reader) -> None:
552 | if (
553 | not (m := p.match) or
554 | m.in_progress
555 | ):
556 | return
557 |
558 | new_match = sr.read_match()
559 |
560 | if m.host != p.id:
561 | return
562 |
563 | if new_match.map_md5 != m.map_md5:
564 | map = await Beatmap.get_beatmap(new_match.map_md5)
565 |
566 | if map:
567 | m.map_md5 = map.hash_md5
568 | m.map_title = map.full_title
569 | m.map_id = map.map_id
570 | m.mode = Mode(map.mode)
571 | else:
572 | m.map_md5 = new_match.map_md5
573 | m.map_title = new_match.map_title
574 | m.map_id = new_match.map_id
575 | m.mode = Mode(new_match.mode)
576 |
577 | if new_match.match_name != m.match_name:
578 | m.match_name = new_match.match_name
579 |
580 | if new_match.freemods != m.freemods:
581 | if new_match.freemods:
582 | m.mods = Mods(m.mods & Mods.MULTIPLAYER)
583 | else:
584 | for slot in m.slots:
585 | if slot.mods:
586 | slot.mods = 0
587 |
588 | m.freemods = new_match.freemods
589 |
590 | if new_match.scoring_type != m.scoring_type:
591 | m.scoring_type = new_match.scoring_type
592 |
593 | if new_match.team_type != m.team_type:
594 | m.team_type = new_match.team_type
595 |
596 | await m.enqueue_state()
597 |
598 |
599 | # id: 44
600 | @register_event(BanchoPackets.OSU_MATCH_START)
601 | async def mp_start(p: Player, sr: Reader) -> None:
602 | if (
603 | not (m := p.match) or
604 | m.in_progress
605 | ):
606 | return
607 |
608 | if p.id != m.host:
609 | log.warn(f"{p.username} tried to start the match, while not being the host.")
610 | return
611 |
612 | for slot in m.slots:
613 | if slot.status & SlotStatus.OCCUPIED:
614 | if slot.status != SlotStatus.NOMAP:
615 | slot.status = SlotStatus.PLAYING
616 | slot.p.enqueue(await writer.MatchStart(m))
617 |
618 | m.in_progress = True
619 |
620 | await m.enqueue_state(lobby=True)
621 |
622 |
623 | # id: 47
624 | @register_event(BanchoPackets.OSU_MATCH_SCORE_UPDATE)
625 | async def mp_score_update(p: Player, sr: Reader) -> None:
626 | if not (m := p.match):
627 | return
628 |
629 | raw_sr = copy.copy(sr)
630 |
631 | raw = raw_sr.read_raw()
632 |
633 | s = sr.read_scoreframe()
634 |
635 | if m.mods & Mods.RELAX or (
636 | m.pp_win_condition and m.scoring_type == ScoringType.SCORE
637 | ):
638 | if os.path.isfile(f".data/beatmaps/{m.map_id}.osu"):
639 | acc = (
640 | general.rag_round(
641 | score.calculate_accuracy(
642 | m.mode,
643 | s.count_300,
644 | s.count_100,
645 | s.count_50,
646 | s.count_geki,
647 | s.count_katu,
648 | s.count_miss,
649 | ),
650 | 2,
651 | )
652 | if s.count_300 != 0
653 | else 0
654 | )
655 |
656 | ez = ezpp_new()
657 |
658 | if m.mods:
659 | ezpp_set_mods(ez, m.mods)
660 |
661 | ezpp_set_combo(ez, s.max_combo)
662 | ezpp_set_nmiss(ez, s.count_miss)
663 | ezpp_set_accuracy_percent(ez, acc)
664 |
665 | ezpp(ez, f".data/beatmaps/{m.map_id}.osu")
666 | s.score = int(ezpp_pp(ez)) if acc != 0 else 0
667 |
668 | ezpp_free(ez)
669 | else:
670 | log.fail(f"MATCH {m.id}: Couldn't find the osu beatmap.")
671 |
672 | slot_id = m.find_user_slot(p)
673 |
674 | if glob.debug:
675 | log.debug(f"{p.username} has slot id {slot_id} and has incoming score update.")
676 |
677 | m.enqueue(await writer.MatchScoreUpdate(s, slot_id, raw))
678 |
679 |
680 | # id: 49
681 | @register_event(BanchoPackets.OSU_MATCH_COMPLETE)
682 | async def mp_complete(p: Player, sr: Reader) -> None:
683 | if (
684 | not (m := p.match) or
685 | not m.in_progress
686 | ):
687 | return
688 |
689 | played = [slot.p for slot in m.slots if slot.status == SlotStatus.PLAYING]
690 |
691 | for slot in m.slots:
692 | if slot.p in played:
693 | slot.status = SlotStatus.NOTREADY
694 |
695 | m.in_progress = False
696 |
697 | for slot in m.slots:
698 | if slot.status & SlotStatus.OCCUPIED and slot.status != SlotStatus.NOMAP:
699 | slot.status = SlotStatus.NOTREADY
700 | slot.skipped = False
701 | slot.loaded = False
702 |
703 | await m.enqueue_state(lobby=True)
704 |
705 | for pl in played:
706 | pl.enqueue(await writer.MatchComplete())
707 |
708 | await m.enqueue_state(lobby=True)
709 |
710 |
711 | # id: 51
712 | @register_event(BanchoPackets.OSU_MATCH_CHANGE_MODS)
713 | async def mp_change_mods(p: Player, sr: Reader) -> None:
714 | mods = sr.read_int32()
715 |
716 | if (
717 | not (m := p.match) or
718 | m.in_progress
719 | ):
720 | return
721 |
722 | if m.freemods:
723 | if m.host == p.id:
724 | if mods & Mods.MULTIPLAYER:
725 | m.mods = Mods(mods & Mods.MULTIPLAYER)
726 |
727 | for slot in m.slots:
728 | if slot.status == SlotStatus.READY:
729 | slot.status = SlotStatus.NOTREADY
730 |
731 | slot = m.find_user(p)
732 |
733 | slot.mods = mods - (mods & Mods.MULTIPLAYER)
734 | else:
735 | if m.host != p.id:
736 | return
737 |
738 | m.mods = Mods(mods)
739 |
740 | for slot in m.slots:
741 | if slot.status & SlotStatus.OCCUPIED and slot.status != SlotStatus.NOMAP:
742 | slot.status = SlotStatus.NOTREADY
743 |
744 | await m.enqueue_state()
745 |
746 |
747 | # id: 52
748 | @register_event(BanchoPackets.OSU_MATCH_LOAD_COMPLETE)
749 | async def mp_load_complete(p: Player, sr: Reader) -> None:
750 | if (
751 | not (m := p.match) or
752 | not m.in_progress
753 | ):
754 | return
755 |
756 | m.find_user(p).loaded = True
757 |
758 | if all(s.loaded for s in m.slots if s.status == SlotStatus.PLAYING):
759 | m.enqueue(await writer.MatchAllReady())
760 |
761 |
762 | # id: 54
763 | @register_event(BanchoPackets.OSU_MATCH_NO_BEATMAP)
764 | async def mp_no_beatmap(p: Player, sr: Reader) -> None:
765 | if not (m := p.match):
766 | return
767 |
768 | m.find_user(p).status = SlotStatus.NOMAP
769 |
770 | await m.enqueue_state()
771 |
772 |
773 | # id: 55
774 | @register_event(BanchoPackets.OSU_MATCH_NOT_READY)
775 | async def mp_unready(p: Player, sr: Reader) -> None:
776 | if not (m := p.match):
777 | return
778 |
779 | slot = m.find_user(p)
780 |
781 | if slot.status == SlotStatus.NOTREADY:
782 | return
783 |
784 | slot.status = SlotStatus.NOTREADY
785 |
786 | await m.enqueue_state()
787 |
788 |
789 | # id: 56
790 | @register_event(BanchoPackets.OSU_MATCH_FAILED)
791 | async def match_failed(p: Player, sr: Reader) -> None:
792 | if (
793 | not (m := p.match) or
794 | not m.in_progress
795 | ):
796 | return
797 |
798 | for slot in m.slots:
799 | if slot.p is not None:
800 | slot.p.enqueue(await writer.MatchPlayerFailed(p.id))
801 |
802 |
803 | # id: 59
804 | @register_event(BanchoPackets.OSU_MATCH_HAS_BEATMAP)
805 | async def has_beatmap(p: Player, sr: Reader) -> None:
806 | if not (m := p.match):
807 | return
808 |
809 | m.find_user(p).status = SlotStatus.NOTREADY
810 |
811 | await m.enqueue_state()
812 |
813 |
814 | # id: 60
815 | @register_event(BanchoPackets.OSU_MATCH_SKIP_REQUEST)
816 | async def skip_request(p: Player, sr: Reader) -> None:
817 | if (
818 | not (m := p.match) or
819 | not m.in_progress
820 | ):
821 | return
822 |
823 | slot = m.find_user(p)
824 |
825 | if slot.skipped:
826 | return
827 |
828 | slot.skipped = True
829 | m.enqueue(await writer.MatchPlayerReqSkip(p.id))
830 |
831 | for slot in m.slots:
832 | if slot.status == SlotStatus.PLAYING and not slot.skipped:
833 | return
834 |
835 | m.enqueue(await writer.MatchSkip())
836 |
837 |
838 | # id: 63
839 | @register_event(BanchoPackets.OSU_CHANNEL_JOIN, restricted=True)
840 | async def join_osu_channel(p: Player, sr: Reader) -> None:
841 | channel = sr.read_str()
842 |
843 | if not (c := glob.channels.get_channel(channel)):
844 | await p.shout("Channel couldn't be found.")
845 | return
846 |
847 | await p.join_channel(c)
848 |
849 |
850 | # id: 70
851 | @register_event(BanchoPackets.OSU_MATCH_TRANSFER_HOST)
852 | async def mp_transfer_host(p: Player, sr: Reader) -> None:
853 | if not (m := p.match):
854 | return
855 |
856 | slot_id = sr.read_int32()
857 |
858 | if not (slot := m.find_slot(slot_id)):
859 | return
860 |
861 | m.host = slot.p.id
862 | slot.p.enqueue(await writer.MatchTransferHost())
863 |
864 | m.enqueue(await writer.Notification(f"{slot.p.username} became host!"))
865 |
866 | await m.enqueue_state()
867 |
868 |
869 | # id: 73 and 74
870 | @register_event(BanchoPackets.OSU_FRIEND_ADD, restricted=True)
871 | @register_event(BanchoPackets.OSU_FRIEND_REMOVE, restricted=True)
872 | async def friend(p: Player, sr: Reader) -> None:
873 | await p.handle_friend(sr.read_int32())
874 |
875 |
876 | # id: 77
877 | @register_event(BanchoPackets.OSU_MATCH_CHANGE_TEAM)
878 | async def mp_change_team(p: Player, sr: Reader) -> None:
879 | if (
880 | not (m := p.match) or
881 | m.in_progress
882 | ):
883 | return
884 |
885 | slot = m.find_user(p)
886 |
887 | if slot.team == SlotTeams.BLUE:
888 | slot.team = SlotTeams.RED
889 | else:
890 | slot.team = SlotTeams.BLUE
891 |
892 | # Should this really be for every occupied slot? or just the user changing team?
893 | for slot in m.slots:
894 | if slot.status & SlotStatus.OCCUPIED and slot.status != SlotStatus.NOMAP:
895 | slot.status = SlotStatus.NOTREADY
896 |
897 | await m.enqueue_state()
898 |
899 |
900 | # id: 78
901 | @register_event(BanchoPackets.OSU_CHANNEL_PART, restricted=True)
902 | async def leave_osu_channel(p: Player, sr: Reader) -> None:
903 | chan = sr.read_str()
904 |
905 | if chan[0] == "#":
906 | await p.leave_channel(chan)
907 |
908 |
909 | # id: 85
910 | @register_event(BanchoPackets.OSU_USER_STATS_REQUEST, restricted=True)
911 | async def request_stats(p: Player, sr: Reader) -> None:
912 | # people id's that current online rn
913 | users = sr.read_i32_list()
914 |
915 | if len(users) > 32:
916 | return
917 |
918 | for user in users:
919 | if user == p.id:
920 | continue
921 |
922 | u = glob.players.get_user(user)
923 |
924 | if not u:
925 | continue
926 |
927 | u.enqueue(await writer.UpdateStats(u))
928 |
929 |
930 | # id: 87
931 | @register_event(BanchoPackets.OSU_MATCH_INVITE)
932 | async def mp_invite(p: Player, sr: Reader) -> None:
933 | if not (m := p.match):
934 | return
935 |
936 | reciever = sr.read_int32()
937 |
938 | if not (reciever := glob.players.get_user(reciever)):
939 | await p.shout("You can't invite someone who's offline.")
940 | return
941 |
942 | await p.send_message(
943 | f"Come join my multiplayer match: [osump://{m.match_id}/{m.match_pass.replace(' ', '_')} {m.match_name}]",
944 | reciever=reciever,
945 | )
946 |
947 |
948 | # id: 90
949 | @register_event(BanchoPackets.OSU_MATCH_CHANGE_PASSWORD)
950 | async def change_pass(p: Player, sr: Reader) -> None:
951 | if (
952 | not (m := p.match) or
953 | m.in_progress
954 | ):
955 | return
956 |
957 | new_data = sr.read_match()
958 |
959 | if m.match_pass == new_data.match_pass:
960 | return
961 |
962 | m.match_pass = new_data.match_pass
963 |
964 | for slot in m.slots:
965 | if slot.status & SlotStatus.OCCUPIED:
966 | slot.p.enqueue(await writer.MatchPassChange(new_data.match_pass))
967 |
968 | await m.enqueue_state(lobby=True)
969 |
970 |
971 | # id: 97
972 | @register_event(BanchoPackets.OSU_USER_PRESENCE_REQUEST, restricted=True)
973 | async def request_stats(p: Player, sr: Reader) -> None:
974 | # people id's that current online rn
975 | users = sr.read_i32_list()
976 |
977 | if len(users) > 256:
978 | return
979 |
980 | for user in users:
981 | if user == p.id:
982 | continue
983 |
984 | u = glob.players.get_user(user)
985 |
986 | if not u:
987 | continue
988 |
989 | u.enqueue(await writer.UserPresence(u))
990 |
991 |
992 | # id: 98
993 | @register_event(BanchoPackets.OSU_USER_PRESENCE_REQUEST_ALL, restricted=True)
994 | async def request_stats(p: Player, sr: Reader) -> None:
995 | for player in glob.players.players:
996 | player.enqueue(await writer.UserPresence(player))
997 |
--------------------------------------------------------------------------------
/events/osu.py:
--------------------------------------------------------------------------------
1 | from objects.beatmap import Beatmap
2 | from objects.channel import Channel
3 | from constants.mods import Mods
4 | from objects import glob
5 | from utils import log
6 | from objects.score import Score, SubmitStatus
7 | from collections import defaultdict
8 | from constants.player import Privileges
9 | from lenhttp import Router, Request
10 | from typing import Callable, Union, Any
11 | from functools import wraps
12 | from anticheat import run
13 | from utils import general
14 | from urllib.parse import unquote
15 | import numpy as np
16 | import aiofiles
17 | import aiohttp
18 | import math
19 | import os
20 | import copy
21 | import bcrypt
22 | import hashlib
23 | import asyncio
24 |
25 |
26 | def check_auth(u: str, pw: str, cho_auth: bool = False, method="GET"):
27 | def decorator(cb: Callable) -> Callable:
28 | @wraps(cb)
29 | async def wrapper(req, *args, **kwargs):
30 | if method == "GET":
31 | player = unquote(req.get_args[u])
32 | password = req.get_args[pw]
33 | else:
34 | player = unquote(req.post_args[u])
35 | password = req.post_args[pw]
36 |
37 | if cho_auth:
38 | if not (
39 | user_info := await glob.sql.fetch(
40 | "SELECT username, id, privileges, "
41 | "passhash, lon, lat, country, cc FROM users "
42 | "WHERE safe_username = %s",
43 | [player.lower().replace(" ", "_")],
44 | )
45 | ):
46 | return b""
47 |
48 | phash = user_info["passhash"].encode("utf-8")
49 | pmd5 = password.encode("utf-8")
50 |
51 | if phash in glob.bcrypt_cache:
52 | if pmd5 != glob.bcrypt_cache[phash]:
53 | log.warn(
54 | f"USER {user_info['username']} ({user_info['id']}) | Login fail. (WRONG PASSWORD)"
55 | )
56 |
57 | return b""
58 | else:
59 | if not bcrypt.checkpw(pmd5, phash):
60 | log.warn(
61 | f"USER {user_info['username']} ({user_info['id']}) | Login fail. (WRONG PASSWORD)"
62 | )
63 |
64 | return b""
65 |
66 | glob.bcrypt_cache[phash] = pmd5
67 | else:
68 | if not (p := glob.players.get_user(player)):
69 | return b""
70 |
71 | if p.passhash in glob.bcrypt_cache:
72 | if password.encode("utf-8") != glob.bcrypt_cache[p.passhash]:
73 | return b""
74 |
75 | return await cb(req, *args, **kwargs)
76 |
77 | return wrapper
78 |
79 | return decorator
80 |
81 |
82 | osu = Router({f"osu.{glob.domain}", f"127.0.0.1:{glob.port}"})
83 |
84 |
85 | @osu.add_endpoint("/users", methods=["POST"])
86 | async def registration(req: Request) -> Union[dict[str, Any], bytes]:
87 | uname = req.post_args["user[username]"]
88 | email = req.post_args["user[user_email]"]
89 | pwd = req.post_args["user[password]"]
90 |
91 | error_response = defaultdict(list)
92 |
93 | if await glob.sql.fetch("SELECT 1 FROM users WHERE username = %s", [uname]):
94 | error_response["username"].append(
95 | "A user with that name already exists in our database."
96 | )
97 |
98 | if await glob.sql.fetch("SELECT 1 FROM users WHERE email = %s", [email]):
99 | error_response["user_email"].append(
100 | "A user with that name already exists in our database."
101 | )
102 |
103 | if error_response:
104 | return req.return_json(200, {"form_error": {"user": error_response}})
105 |
106 | if req.post_args["check"] == "0":
107 | pw_md5 = hashlib.md5(pwd.encode()).hexdigest().encode()
108 | pw_bcrypt = bcrypt.hashpw(pw_md5, bcrypt.gensalt())
109 |
110 | id = await glob.sql.execute(
111 | "INSERT INTO users (id, username, safe_username, passhash, "
112 | "email, privileges, latest_activity_time, registered_time) "
113 | "VALUES (NULL, %s, %s, %s, %s, %s, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())",
114 | [
115 | uname,
116 | uname.lower().replace(" ", "_"),
117 | pw_bcrypt,
118 | email,
119 | Privileges.PENDING.value,
120 | ],
121 | )
122 |
123 | await glob.sql.execute("INSERT INTO stats (id) VALUES (%s)", [id])
124 | await glob.sql.execute("INSERT INTO stats_rx (id) VALUES (%s)", [id])
125 |
126 | return b"ok"
127 |
128 |
129 | async def save_beatmap_file(id: int) -> None:
130 | if not os.path.exists(f".data/beatmaps/{id}.osu"):
131 | async with aiohttp.ClientSession() as sess:
132 | # I hope this is legal.
133 | async with sess.get(
134 | f"https://osu.ppy.sh/web/osu-getosufile.php?q={id}",
135 | headers={"user-agent": "osu!"},
136 | ) as resp:
137 | if not await resp.text():
138 | log.fail(
139 | f"Couldn't fetch the .osu file of {id}. Maybe because api rate limit?"
140 | )
141 | return b""
142 |
143 | async with aiofiles.open(f".data/beatmaps/{id}.osu", "w+") as osu:
144 | await osu.write(await resp.text())
145 |
146 |
147 | @osu.add_endpoint("/web/bancho_connect.php")
148 | @check_auth("u", "h", cho_auth = True)
149 | async def bancho_connect(req: Request) -> bytes:
150 | # TODO: make some verification (ch means client hash)
151 | # "error: verify" is a thing
152 | return req.headers["CF-IPCountry"].lower().encode()
153 |
154 |
155 | @osu.add_endpoint("/web/osu-osz2-getscores.php")
156 | @check_auth("us", "ha")
157 | async def get_scores(req: Request) -> bytes:
158 | hash = req.get_args["c"]
159 | mode = int(req.get_args["m"])
160 |
161 | if not hash in glob.beatmaps:
162 | b = await Beatmap.get_beatmap(hash, req.get_args["i"])
163 | else:
164 | b = glob.beatmaps[hash]
165 |
166 | if not b:
167 | if not hash in glob.beatmaps:
168 | glob.beatmaps[hash] = None
169 |
170 | return b"-1|true"
171 |
172 | if not hash in glob.beatmaps:
173 | b.approved += 1
174 |
175 | if b.approved - 1 <= 0:
176 | if not hash in glob.beatmaps:
177 | glob.beatmaps[hash] = b
178 |
179 | return b"0|false"
180 |
181 | # no need for check, as its in the decorator
182 | p = glob.players.get_user(unquote(req.get_args["us"]))
183 |
184 | # pretty sus
185 | if not int(req.get_args["mods"]) & Mods.RELAX and p.relax:
186 | p.relax = False
187 |
188 | if int(req.get_args["mods"]) & Mods.RELAX and not p.relax:
189 | p.relax = True
190 |
191 | ret = b.web_format
192 | order = ("score", "pp")[p.relax]
193 |
194 | if b.approved >= 1:
195 | if not (
196 | data := await glob.sql.fetch(
197 | "SELECT id FROM scores WHERE user_id = %s "
198 | "AND relax = %s AND hash_md5 = %s AND mode = %s "
199 | f"AND status = 3 ORDER BY {order} DESC LIMIT 1",
200 | (p.id, p.relax, b.hash_md5, mode),
201 | )
202 | ):
203 | ret += "\n"
204 | else:
205 | s = await Score.set_data_from_sql(data["id"])
206 |
207 | ret += s.web_format
208 |
209 | async for play in glob.sql.iterall(
210 | "SELECT s.id FROM scores s INNER JOIN users u ON u.id = s.user_id "
211 | "WHERE s.hash_md5 = %s AND s.mode = %s AND s.relax = %s AND s.status = 3 "
212 | f"AND u.privileges & 4 ORDER BY s.{order} DESC, s.submitted ASC LIMIT 50",
213 | (b.hash_md5, mode, p.relax),
214 | ):
215 | ls = await Score.set_data_from_sql(play["id"])
216 |
217 | await ls.calculate_position()
218 |
219 | ls.map.scores += 1
220 |
221 | ret += ls.web_format
222 |
223 | asyncio.create_task(save_beatmap_file(b.map_id))
224 |
225 | if not hash in glob.beatmaps:
226 | glob.beatmaps[hash] = b
227 |
228 | return ret.encode() # placeholder
229 |
230 |
231 | @osu.add_endpoint("/web/osu-submit-modular-selector.php", methods=["POST"])
232 | async def score_submission(req: Request) -> bytes:
233 | if (ver := req.post_args["osuver"])[:4] != "2021":
234 | return b"error: oldver"
235 |
236 | submission_key = f"osu!-scoreburgr---------{ver}"
237 |
238 | s = await Score.set_data_from_submission(
239 | req.post_args["score"],
240 | req.post_args["iv"],
241 | submission_key,
242 | int(req.post_args["x"]),
243 | )
244 |
245 | if (
246 | not s or
247 | not s.player or
248 | not s.map
249 | ):
250 | return b"error: no"
251 |
252 | passed = s.status >= SubmitStatus.PASSED
253 |
254 | # i hate this
255 | s.id = await glob.sql.fetch("SELECT id FROM scores ORDER BY id DESC LIMIT 1")
256 | if not s.id:
257 | s.id = 1
258 | else:
259 | s.id = s.id["id"] + 1
260 |
261 | s.play_time = req.post_args["st" if passed else "ft"]
262 |
263 | # handle needed things, if the map is ranked.
264 | if s.map.approved >= 1:
265 | if not s.player.is_restricted:
266 | s.map.plays += 1
267 |
268 | if passed:
269 | s.map.passes += 1
270 |
271 | # restrict the player if they
272 | # somehow managed to submit a
273 | # score without a replay.
274 | if "score" not in req.files.keys():
275 | await s.player.restrict()
276 | return b"error: no"
277 |
278 | with open(f".data/replays/{s.id}.osr", "wb+") as file:
279 | file.write(req.files["score"])
280 |
281 | await glob.sql.execute(
282 | "UPDATE beatmaps SET plays = %s, passes = %s WHERE hash = %s",
283 | (s.map.plays, s.map.passes, s.map.hash_md5),
284 | )
285 |
286 | await s.save_to_db()
287 |
288 | if passed:
289 | stats = s.player
290 |
291 | # check if the user is playing for the first time
292 | prev_stats = None
293 |
294 | if stats.total_score > 0:
295 | prev_stats = copy.copy(stats)
296 |
297 | # calculate new stats
298 | if s.map.approved >= 1:
299 |
300 | stats.playcount += 1
301 | stats.total_score += s.score
302 |
303 | if s.status == SubmitStatus.BEST:
304 | table = ("stats", "stats_rx")[s.relax]
305 |
306 | rank = await glob.sql.fetch(
307 | f"SELECT COUNT(*) AS rank FROM {table} t "
308 | "INNER JOIN users u ON u.id = t.id "
309 | "WHERE t.id != %s AND t.pp_std > %s "
310 | "ORDER BY t.pp_std DESC, t.total_score_std DESC LIMIT 1",
311 | (stats.id, stats.pp),
312 | )
313 |
314 | stats.rank = rank["rank"] + 1
315 |
316 | sus = s.score
317 |
318 | if s.pb:
319 | sus -= s.pb.score
320 |
321 | stats.ranked_score += sus
322 |
323 | scores = await glob.sql.fetchall(
324 | "SELECT pp, accuracy FROM scores "
325 | "WHERE user_id = %s AND mode = %s "
326 | "AND status = 3 AND relax = %s",
327 | (stats.id, s.mode.value, s.relax),
328 | )
329 |
330 | avg_accuracy = np.array([x[1] for x in scores])
331 |
332 | stats.accuracy = float(np.mean(avg_accuracy))
333 |
334 | weighted = np.sum(
335 | [score[0] * 0.95 ** (place) for place, score in enumerate(scores)]
336 | )
337 | weighted += 416.6667 * (1 - 0.9994 ** len(scores))
338 | stats.pp = math.ceil(weighted)
339 |
340 | await stats.update_stats(s.mode, s.relax)
341 |
342 | if s.position == 1 and not stats.is_restricted:
343 | modes = {
344 | 0: "osu!",
345 | 1: "osu!taiko",
346 | 2: "osu!catch",
347 | 3: "osu!mania"
348 | }[s.mode]
349 |
350 | chan: Channel = glob.channels.get_channel("#announce")
351 |
352 | await chan.send(
353 | f"{s.player.embed} achieved #1 on {s.map.embed} ({modes}) [{'RX' if s.relax else 'VN'}]",
354 | sender=glob.bot,
355 | )
356 |
357 | if not s.relax:
358 | ret: list = []
359 |
360 | ret.append(
361 | "|".join(
362 | (
363 | f"beatmapId:{s.map.map_id}",
364 | f"beatmapSetId:{s.map.set_id}",
365 | f"beatmapPlaycount:{s.map.plays}",
366 | f"beatmapPasscount:{s.map.passes}",
367 | f"approvedDate:{s.map.approved_date}",
368 | )
369 | )
370 | )
371 |
372 | ret.append(
373 | "|".join(
374 | (
375 | "chartId:beatmap",
376 | f"chartUrl:{s.map.url}",
377 | "chartName:deez nuts",
378 | *(
379 | (
380 | Beatmap.add_chart("rank", None, s.position),
381 | Beatmap.add_chart("accuracy", None, s.accuracy),
382 | Beatmap.add_chart("maxCombo", None, s.max_combo),
383 | Beatmap.add_chart("rankedScore", None, s.score),
384 | Beatmap.add_chart("totalScore", None, s.score),
385 | Beatmap.add_chart("pp", None, math.ceil(s.pp)),
386 | )
387 | if not s.pb
388 | else (
389 | Beatmap.add_chart("rank", s.pb.position, s.position),
390 | Beatmap.add_chart("accuracy", s.pb.accuracy, s.accuracy),
391 | Beatmap.add_chart("maxCombo", s.pb.max_combo, s.max_combo),
392 | Beatmap.add_chart("rankedScore", s.pb.score, s.score),
393 | Beatmap.add_chart("totalScore", s.pb.score, s.score),
394 | Beatmap.add_chart( "pp", math.ceil(s.pb.pp), math.ceil(s.pp)),
395 | )
396 | ),
397 | f"onlineScoreId:{s.id}",
398 | )
399 | )
400 | )
401 |
402 | ret.append(
403 | "|".join(
404 | (
405 | "chartId:overall",
406 | f"chartUrl:{s.player.url}",
407 | "chartName:penis",
408 | *(
409 | (
410 | Beatmap.add_chart("rank", None, stats.rank),
411 | Beatmap.add_chart("accuracy", None, stats.accuracy),
412 | Beatmap.add_chart("maxCombo", None, 0),
413 | Beatmap.add_chart("rankedScore", None, stats.ranked_score),
414 | Beatmap.add_chart("totalScore", None, stats.total_score),
415 | Beatmap.add_chart("pp", None, stats.pp),
416 | )
417 | if not prev_stats
418 | else (
419 | Beatmap.add_chart("rank", prev_stats.rank, stats.rank),
420 | Beatmap.add_chart("accuracy", prev_stats.accuracy, stats.accuracy),
421 | Beatmap.add_chart("maxCombo", 0, 0),
422 | Beatmap.add_chart("rankedScore", prev_stats.ranked_score, stats.ranked_score,),
423 | Beatmap.add_chart("totalScore", prev_stats.total_score, stats.total_score,),
424 | Beatmap.add_chart("pp", prev_stats.pp, stats.pp),
425 | )
426 | ),
427 | # achievements can wait
428 | f"achievements-new:osu-combo-500+deez+nuts",
429 | )
430 | )
431 | )
432 |
433 | asyncio.create_task(
434 | run.run_anticheat(
435 | s, f".data/replays/{s.id}.osr", f".data/beatmaps/{s.map.map_id}.osu"
436 | )
437 | )
438 |
439 | stats.last_score = s
440 | else:
441 | return b"error: no"
442 | else:
443 | return b"error: no"
444 |
445 | return "\n".join(ret).encode()
446 |
447 |
448 | @osu.add_endpoint("/web/osu-getreplay.php")
449 | @check_auth("u", "h")
450 | async def get_replay(req: Request) -> bytes:
451 | async with aiofiles.open(f".data/replays/{req.get_args['c']}.osr", "rb") as raw:
452 | if replay := await raw.read():
453 | return replay
454 |
455 | return b""
456 |
457 | @osu.add_endpoint("/web/osu-getfriends.php")
458 | @check_auth("u", "h")
459 | async def get_friends(req: Request) -> bytes:
460 | p = await glob.players.get_user_offline(unquote(req.get_args["u"]))
461 |
462 | await p.get_friends()
463 |
464 | return '\n'.join(map(str, p.friends)).encode()
465 |
466 |
467 | @osu.add_endpoint("/web/osu-markasread.php")
468 | @check_auth("u", "h")
469 | async def lastfm(req: Request) -> bytes:
470 | if not (chan := glob.channels.get_channel(req.get_args["channel"])):
471 | return b""
472 |
473 | # TODO: maybe make a mail system???
474 | return b""
475 |
476 |
477 | @osu.add_endpoint("/web/lastfm.php")
478 | @check_auth("us", "ha")
479 | async def lastfm(req: Request) -> bytes:
480 | # something odd in client detected
481 | # TODO: add enums to check abnormal stuff
482 | if req.get_args["b"][0] == "a":
483 | return b"-3"
484 |
485 | # if nothing odd happens... then keep checking
486 | return b""
487 |
488 |
489 | @osu.add_endpoint("/web/osu-getseasonal.php")
490 | async def get_seasonal(req: Request) -> bytes:
491 | # hmmm... it seems like there's nothing special yet
492 | # TODO: make a config file for this?
493 | return b"[]"
494 |
495 |
496 | @osu.add_endpoint("/web/osu-error.php", methods=["POST"])
497 | async def get_osu_error(req: Request) -> bytes:
498 | # not really our problem though :trolley:
499 | # not sending this to bancho, though.
500 | return b""
501 |
502 |
503 | @osu.add_endpoint("/web/osu-comment.php", methods=["POST"])
504 | @check_auth("u", "p", method="POST")
505 | async def get_beatmap_comments(req: Request) -> bytes:
506 | if not req.post_args:
507 | return b""
508 |
509 | log.info(req.post_args)
510 | return b""
511 |
512 |
513 | @osu.add_endpoint("/web/osu-screenshot.php", methods=["POST"])
514 | @check_auth("u", "p", method="POST")
515 | async def post_screenshot(req: Request) -> bytes:
516 | id = general.random_string(8)
517 |
518 | async with aiofiles.open(f".data/ss/{id}.png", "wb+") as ss:
519 | await ss.write(req.files["ss"])
520 |
521 | return f"{id}.png".encode()
522 |
523 |
524 | @osu.add_endpoint("/ss/.png")
525 | async def get_screenshot(req: Request, ssid: int) -> bytes:
526 | if os.path.isfile((path := f".data/ss/{ssid}.png")):
527 | async with aiofiles.open(path, "rb") as ss:
528 | return await ss.read()
529 |
530 | return b"no screenshot with that id."
531 |
--------------------------------------------------------------------------------
/lib/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osumitsuha/Ragnarok/948214e20d4669ad1fe22416552665174f70363c/lib/__init__.py
--------------------------------------------------------------------------------
/lib/database.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Optional, List, Dict
2 | import aiomysql
3 |
4 |
5 | class Database:
6 | def __init__(self):
7 | """Simple database class object."""
8 | self.pool = None
9 |
10 | async def connect(self, config: Dict[str, str]) -> None:
11 | self.pool = await aiomysql.create_pool(**config)
12 |
13 | async def disconnect(self) -> None:
14 | self.pool.close()
15 | await self.pool.wait_close()
16 |
17 | async def _fetch(self, query: str, params: Optional[list[Any]] = None, _dict: bool = False):
18 | if _dict:
19 | cursor = aiomysql.DictCursor
20 | else:
21 | cursor = aiomysql.Cursor
22 |
23 | async with self.pool.acquire() as conn:
24 | async with conn.cursor(cursor) as cur:
25 | await cur.execute(query, params)
26 |
27 | return conn, cur
28 |
29 | async def execute(self, query: str, params: Optional[List[Any]] = None) -> int:
30 | conn, cur = await self._fetch(query, params)
31 |
32 | await conn.commit()
33 | return cur.lastrowid
34 |
35 | async def fetchall(
36 | self, query: str, params: Optional[List[Any]] = None, _dict: bool = False
37 | ) -> Dict[str, Any]:
38 | _, cur = await self._fetch(query, params)
39 |
40 | return await cur.fetchall()
41 |
42 | async def fetch(
43 | self, query: str, params: Optional[List[Any]] = None, _dict: bool = True
44 | ) -> Dict[str, Any]:
45 | _, cur = await self._fetch(query, params, _dict)
46 |
47 | return await cur.fetchone()
48 |
49 | async def iterall(
50 | self, query: str, params: Optional[List[Any]] = None, _dict: bool = True
51 | ) -> Dict[str, Any]:
52 | _, cur = await self._fetch(query, params, _dict)
53 |
54 | async for row in cur:
55 | yield row
56 |
--------------------------------------------------------------------------------
/objects/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osumitsuha/Ragnarok/948214e20d4669ad1fe22416552665174f70363c/objects/__init__.py
--------------------------------------------------------------------------------
/objects/beatmap.py:
--------------------------------------------------------------------------------
1 | import aiohttp
2 | from objects import glob
3 | from utils import log
4 | from constants.playmode import Mode
5 | from constants.beatmap import Approved
6 |
7 |
8 | class Beatmap:
9 | def __init__(self):
10 | self.set_id = 0
11 | self.map_id = 0
12 | self.hash_md5 = ""
13 |
14 | self.title = ""
15 | self.title_unicode = "" # added
16 | self.version = ""
17 | self.artist = ""
18 | self.artist_unicode = "" # added
19 | self.creator = ""
20 | self.creator_id = 0
21 |
22 | self.stars = 0.0
23 | self.od = 0.0
24 | self.ar = 0.0
25 | self.hp = 0.0
26 | self.cs = 0.0
27 | self.mode = 0
28 | self.bpm = 0.0
29 | self.max_combo = 0
30 |
31 | self.approved = Approved.PENDING
32 |
33 | self.submit_date = ""
34 | self.approved_date = ""
35 | self.latest_update = ""
36 |
37 | self.length_total = 0
38 | self.drain = 0
39 |
40 | self.plays = 0
41 | self.passes = 0
42 | self.favorites = 0
43 |
44 | self.rating = 0 # added
45 |
46 | self.scores: int = 0
47 |
48 | @property
49 | def file(self) -> str:
50 | return f"{self.map_id}.osu"
51 |
52 | @property
53 | def pass_procent(self) -> float:
54 | return self.passes / self.plays * 100
55 |
56 | @property
57 | def full_title(self) -> str:
58 | return f"{self.artist} - {self.title} [{self.version}]"
59 |
60 | @property
61 | def display_title(self) -> str:
62 | return f"[bold:0,size:20]{self.artist_unicode}|{self.title_unicode}" # You didn't see this
63 |
64 | @property
65 | def url(self) -> str:
66 | return f"https://mitsuha.pw/beatmapsets/{self.set_id}#{self.map_id}"
67 |
68 | @property
69 | def embed(self) -> str:
70 | return f"[{self.url} {self.full_title}]"
71 |
72 | @property
73 | def web_format(self) -> str:
74 | return f"{self.approved}|false|{self.map_id}|{self.set_id}|{self.scores}\n0\n{self.display_title}\n{self.rating}"
75 |
76 | @staticmethod
77 | def add_chart(name: str, prev=None, after=None) -> str:
78 | return f"{name}Before:{prev if prev else ''}|{name}After:{after}"
79 |
80 | @classmethod
81 | async def _get_beatmap_from_sql(cls, hash: str, beatmap_id: int) -> "Beatmap":
82 | b = cls()
83 |
84 | if not (ret := await glob.sql.fetch(
85 | "SELECT set_id, map_id, hash, title, title_unicode, "
86 | "version, artist, artist_unicode, creator, creator_id, stars, "
87 | "od, ar, hp, cs, mode, bpm, approved, submit_date, approved_date, "
88 | "latest_update, length, drain, plays, passes, favorites, rating "
89 | f"FROM beatmaps WHERE {'hash' if hash else 'map_id'} = %s",
90 | (hash or beatmap_id),
91 | )):
92 | return
93 |
94 | b.set_id = ret["set_id"]
95 | b.map_id = ret["map_id"]
96 | b.hash_md5 = ret["hash"]
97 |
98 | b.title = ret["title"]
99 | b.title_unicode = ret["title_unicode"] # added
100 | b.version = ret["version"]
101 | b.artist = ret["artist"]
102 | b.artist_unicode = ret["artist_unicode"] # added
103 | b.creator = ret["creator"]
104 | b.creator_id = ret["creator_id"]
105 |
106 | b.stars = ret["stars"]
107 | b.od = ret["od"]
108 | b.ar = ret["ar"]
109 | b.hp = ret["hp"]
110 | b.cs = ret["cs"]
111 | b.mode = ret["mode"]
112 | b.bpm = ret["bpm"]
113 |
114 | b.approved = ret["approved"]
115 |
116 | b.submit_date = ret["submit_date"]
117 | b.approved_date = ret["approved_date"]
118 | b.latest_update = ret["latest_update"]
119 |
120 | b.length_total = ret["length"]
121 | b.drain = ret["drain"]
122 |
123 | b.plays = ret["plays"]
124 | b.passes = ret["passes"]
125 | b.favorites = ret["favorites"]
126 |
127 | b.rating = ret["rating"]
128 |
129 | return b
130 |
131 | async def add_to_db(self) -> None:
132 | if await glob.sql.fetch(
133 | "SELECT 1 FROM beatmaps WHERE hash = %s LIMIT 1", (self.hash_md5)
134 | ):
135 | return # ignore beatmaps there are already in db
136 |
137 | await glob.sql.execute(
138 | "INSERT INTO beatmaps (set_id, map_id, hash, title, title_unicode, "
139 | "version, artist, artist_unicode, creator, creator_id, stars, "
140 | "od, ar, hp, cs, mode, bpm, max_combo, approved, submit_date, approved_date, "
141 | "latest_update, length, drain, plays, passes, favorites, rating) "
142 | "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, "
143 | "%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
144 | [*self.__dict__.values()][:-1],
145 | )
146 |
147 | log.info(f"Saved {self.full_title} ({self.hash_md5}) into database")
148 |
149 | @classmethod
150 | async def _get_beatmap_from_osuapi(cls, hash: str, beatmap_id) -> "Beatmap":
151 | b = cls()
152 |
153 | async with aiohttp.ClientSession() as session:
154 | # get the beatmap with its hash
155 | async with session.get(
156 | "https://osu.ppy.sh/api/get_beatmaps?k="
157 | + glob.osu_key
158 | + f"&{'h' if hash else 'b'}="
159 | + hash
160 | or beatmap_id
161 | ) as resp:
162 | if not resp or resp.status != 200:
163 | return
164 |
165 | if not (b_data := await resp.json()):
166 | return
167 |
168 | ret = b_data[0]
169 |
170 | b.set_id = int(ret["beatmapset_id"])
171 | b.map_id = int(ret["beatmap_id"])
172 | b.hash_md5 = ret["file_md5"]
173 |
174 | b.title = ret["title"]
175 | b.title_unicode = ret["title_unicode"] or ret["title"] # added
176 | b.version = ret["version"]
177 | b.artist = ret["artist"]
178 | b.artist_unicode = ret["artist_unicode"] or ret["artist"] # added
179 | b.creator = ret["creator"]
180 | b.creator_id = int(ret["creator_id"])
181 |
182 | b.stars = float(ret["difficultyrating"])
183 | b.od = float(ret["diff_overall"])
184 | b.ar = float(ret["diff_approach"])
185 | b.hp = float(ret["diff_drain"])
186 | b.cs = float(ret["diff_size"])
187 | b.mode = Mode(int(ret["mode"])).value
188 | b.bpm = float(ret["bpm"])
189 | b.max_combo = (
190 | 0 if ret["max_combo"] is None else int(ret["max_combo"])
191 | ) # fix taiko and mania "null" combo
192 |
193 | b.approved = Approved(int(ret["approved"])).value
194 |
195 | b.submit_date = ret["submit_date"]
196 |
197 | if ret["approved_date"]:
198 | b.approved_date = ret["approved_date"]
199 | else:
200 | b.approved_date = "0"
201 |
202 | b.latest_update = ret["last_update"]
203 |
204 | b.length_total = int(ret["total_length"])
205 | b.drain = int(ret["hit_length"])
206 |
207 | b.plays = 0
208 | b.passes = 0
209 | b.favorites = 0
210 |
211 | b.rating = float(ret["rating"])
212 |
213 | await b.add_to_db()
214 |
215 | return b
216 |
217 | @classmethod
218 | async def get_beatmap(cls, hash: str = "", beatmap_id=0) -> "Beatmap":
219 | self = cls() # trollface
220 |
221 | if not (ret := await self._get_beatmap_from_sql(hash, beatmap_id)):
222 | if not (ret := await self._get_beatmap_from_osuapi(hash, beatmap_id)):
223 | return
224 |
225 | return ret
226 |
--------------------------------------------------------------------------------
/objects/bot.py:
--------------------------------------------------------------------------------
1 | from objects.player import Player
2 | from constants.player import bStatus
3 | from packets import writer
4 | from objects import glob
5 |
6 |
7 | class Louise:
8 | @staticmethod
9 | async def init() -> bool:
10 | if not (
11 | bot := await glob.sql.fetch(
12 | "SELECT id, username, privileges, passhash FROM users WHERE id = 1"
13 | )
14 | ):
15 | return False
16 |
17 | p = Player(bot["username"], bot["id"], bot["privileges"], bot["passhash"])
18 |
19 | p.status = bStatus.WATCHING
20 | p.status_text = "over deez nutz"
21 |
22 | p.bot = True
23 |
24 | glob.bot = p
25 |
26 | glob.players.add_user(p)
27 |
28 | for player in glob.players.players:
29 | player.enqueue(await writer.UserPresence(p) + await writer.UpdateStats(p))
30 |
31 | return True
32 |
--------------------------------------------------------------------------------
/objects/channel.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 | from packets import writer
3 | from objects import glob
4 | from typing import Any
5 | from utils import log
6 |
7 | if TYPE_CHECKING:
8 | from objects.player import Player
9 |
10 |
11 | class Channel:
12 | def __init__(self, **kwargs):
13 | self.name: str = kwargs.get("name", "unnamed") # display name
14 | self._name: str = kwargs.get("raw", self.name) # real name. fx #multi_1
15 |
16 | self.description: str = kwargs.get("description", "An osu! channel.")
17 |
18 | self.public: bool = kwargs.get("public", True)
19 | self.read_only: bool = kwargs.get("read_only", False)
20 | self.auto_join: bool = kwargs.get("auto_join", False)
21 |
22 | self.staff: bool = kwargs.get("staff", False)
23 |
24 | self.connected: list[Player] = []
25 |
26 | @property
27 | def is_multi(self):
28 | return self.name == "#multiplayer"
29 |
30 | def enqueue(self, data: bytes, ignore: list[int] = []) -> None:
31 | for p in self.connected:
32 | if p.id not in ignore:
33 | p.enqueue(data)
34 |
35 | async def update_info(self) -> None:
36 | glob.players.enqueue(await writer.ChanInfo(self._name))
37 |
38 | async def force_join(self, p: "Player") -> None:
39 | if self in p.channels:
40 | return
41 |
42 | p.channels.append(self)
43 | self.connected.append(p)
44 |
45 | p.enqueue(await writer.ChanJoinSuccess(self._name))
46 |
47 | await self.update_info()
48 |
49 | async def kick(self, p: "Player") -> None:
50 | if not self in p.channels:
51 | return
52 |
53 | p.channels.remove(self)
54 | self.connected.remove(p)
55 |
56 | p.enqueue(await writer.ChanKick(self._name))
57 |
58 | await self.update_info()
59 |
60 | async def send(self, message: str, sender: "Player") -> None:
61 | if not sender.bot:
62 | if not (self in sender.channels or self.read_only):
63 | return
64 |
65 | ret = await writer.SendMessage(
66 | sender=sender.username, message=message, channel=self.name, id=sender.id
67 | )
68 |
69 | self.enqueue(ret, ignore=[sender.id])
70 |
71 | log.chat(f"<{sender.username}> {message} [{self._name}]")
72 |
--------------------------------------------------------------------------------
/objects/collections.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING, Any, Union
2 |
3 | from objects import glob
4 | from objects.channel import Channel
5 | from objects.match import Match
6 | from objects.player import Player
7 |
8 |
9 | class Tokens:
10 | def __init__(self):
11 | self.players: list[Player] = []
12 |
13 | def add_user(self, p: Player) -> None:
14 | self.players.append(p)
15 |
16 | def remove_user(self, p: Player) -> None:
17 | self.players.remove(p)
18 |
19 | def get_user(self, value: Union[str, int]) -> Player:
20 | for p in self.players:
21 | if (
22 | p.id == value
23 | or p.username == value
24 | or p.token == value
25 | or p.safe_name == value
26 | ):
27 | return p
28 |
29 | async def get_user_offline(self, value: Union[str, int]) -> Player:
30 | if p := self.get_user(value):
31 | return p
32 |
33 | if p := await self.from_sql(value):
34 | return p
35 |
36 | async def from_sql(self, value: Union[str, int]) -> Player:
37 | data = await glob.sql.fetch(
38 | "SELECT username, id, privileges, passhash FROM users "
39 | "WHERE (id = %s OR username = %s OR safe_username = %s)",
40 | (value, value, value),
41 | )
42 |
43 | if not data:
44 | return
45 |
46 | p = Player(**data)
47 |
48 | return p
49 |
50 | def enqueue(self, packet: bytes) -> None:
51 | for p in self.players:
52 | p.enqueue(packet)
53 |
54 |
55 | class Channels:
56 | def __init__(self):
57 | self.channels: list[Channel] = []
58 |
59 | def add_channel(self, data: dict[str, Any]) -> None:
60 | self.channels.append(Channel(**data))
61 |
62 | def remove_channel(self, c: Channel) -> None:
63 | self.channels.remove(c)
64 |
65 | def get_channel(self, name: str) -> Channel:
66 | for chan in self.channels:
67 | if chan._name == name:
68 | return chan
69 |
70 |
71 | class Matches:
72 | def __init__(self):
73 | self.matches: "Match" = []
74 |
75 | async def remove_match(self, m: "Match"):
76 | if m in self.matches:
77 | self.matches.remove(m)
78 |
79 | async def find_match(self, match_id: int):
80 | for match in self.matches:
81 | if match_id == match.match_id:
82 | return match
83 |
84 | async def add_match(self, m: "Match"):
85 | self.matches.append(m)
86 |
--------------------------------------------------------------------------------
/objects/glob.py:
--------------------------------------------------------------------------------
1 | from typing import Union, Any, Callable, Pattern, TYPE_CHECKING
2 | from lenhttp import Router, LenHTTP
3 | from lib.database import Database
4 | from config import conf
5 | import re
6 |
7 | if TYPE_CHECKING:
8 | from objects.collections import Tokens, Channels, Matches
9 | from constants.commands import Command
10 | from objects.beatmap import Beatmap
11 | from objects.player import Player
12 | from packets.reader import Packet
13 |
14 |
15 | server: LenHTTP = None
16 |
17 | debug: bool = conf["server"]["debug"]
18 | domain: str = conf["server"]["domain"]
19 | port: int = conf["server"]["port"]
20 |
21 | bancho: Router = None
22 | avatar: Router = None
23 | osu: Router = None
24 |
25 | packets: dict[int, "Packet"] = {}
26 | tasks: list[dict[str, Callable]] = []
27 |
28 | bot: "Player" = None
29 |
30 | prefix: str = "!"
31 |
32 | config: dict[str, Union[dict[str, Any], str, bool]] = conf
33 |
34 | sql: Database = None
35 |
36 | bcrypt_cache: dict[str, str] = {}
37 |
38 | title_card: str = '''
39 | . . .o .. o
40 | o . o o.o
41 | ...oo.
42 | ________[]_
43 | _______|_o_o_o_o_o\___
44 | \\""""""""""""""""""""/
45 | \ ... . . .. ./
46 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
47 | osu!ragnarok, an osu!bancho & /web/ emulator.
48 | Simon & Aoba
49 | '''
50 |
51 |
52 | players: "Tokens" = None
53 |
54 | channels: "Channels" = None
55 |
56 | matches: "Matches" = None
57 |
58 | osu_key: str = config["api_conf"]["osu_api_key"]
59 |
60 | beatmaps: dict[str, "Beatmap"] = {}
61 |
62 | regex: dict[str, Pattern[str]] = {
63 | "np": re.compile(
64 | rf"\x01ACTION is (?:listening|editing|playing|watching) to \[https://osu.{domain}/beatmapsets/[0-9].*#/(\d*)"
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/objects/match.py:
--------------------------------------------------------------------------------
1 | from constants.match import SlotStatus, SlotTeams, TeamType, ScoringType
2 | from objects.channel import Channel
3 | from constants.playmode import Mode
4 | from constants.mods import Mods
5 | from packets import writer
6 | from objects import glob
7 | from typing import TYPE_CHECKING
8 |
9 | if TYPE_CHECKING:
10 | from objects.player import Player
11 |
12 |
13 | class Players:
14 | def __init__(self):
15 | self.p: "Player" = None # no superman :pensive:
16 | self.mods: Mods = Mods.NONE
17 | self.host: bool = False
18 | self.status: SlotStatus = SlotStatus.OPEN
19 | self.team: SlotTeams = SlotTeams.NEUTRAL
20 | self.loaded: bool = False
21 | self.skipped: bool = False
22 |
23 | def reset(self):
24 | self.p = None
25 | self.mods = Mods.NONE
26 | self.host = False
27 | self.status = SlotStatus.OPEN
28 | self.team = SlotTeams.NEUTRAL
29 | self.loaded = False
30 | self.skipped = False
31 |
32 | def copy_from(self, old):
33 | self.p = old.p
34 | self.mods = old.mods
35 | self.host = old.host
36 | self.status = old.status
37 | self.team = old.team
38 | self.loaded = old.loaded
39 | self.skipped = old.skipped
40 |
41 |
42 | class Match:
43 | def __init__(self):
44 | self.match_id: int = 0
45 | self.match_name: str = ""
46 | self.match_pass: str = ""
47 |
48 | self.host = None
49 | self.in_progress: bool = False
50 |
51 | self.map_id: int = 0
52 | self.map_title: str = ""
53 | self.map_md5: str = ""
54 |
55 | self.slots: list[Players] = [Players() for _ in range(0, 16)]
56 |
57 | self.mode: Mode = Mode.OSU
58 | self.mods: Mods = Mods.NONE
59 | self.freemods: bool = False
60 |
61 | self.scoring_type: ScoringType = ScoringType.SCORE
62 | self.pp_win_condition: bool = False
63 | self.team_type: TeamType = TeamType.HEAD2HEAD
64 |
65 | self.seed: int = 0
66 |
67 | self.connected: list = []
68 |
69 | self.chat: Channel = None
70 | self.locked: bool = False
71 |
72 | def __repr__(self) -> str:
73 | return f"MATCH-{self.match_id}"
74 |
75 | def get_free_slot(self) -> int:
76 | for id, slot in enumerate(self.slots):
77 | if slot.status == SlotStatus.OPEN:
78 | return id
79 |
80 | def find_host(self) -> Players:
81 | for slot in self.slots:
82 | if slot.p.id == self.host:
83 | return slot
84 |
85 | def find_user(self, p: "Player") -> Players:
86 | for slot in self.slots:
87 | if slot.p == p:
88 | return slot
89 |
90 | def find_user_slot(self, p: "Player") -> int:
91 | for id, slot in enumerate(self.slots):
92 | if slot.p == p:
93 | return id
94 |
95 | def find_slot(self, slot_id: int) -> Players:
96 | for id, slot in enumerate(self.slots):
97 | if id == slot_id:
98 | return slot
99 |
100 | async def transfer_host(self, slot) -> None:
101 | self.host = slot.p.id
102 |
103 | slot.p.enqueue(await writer.MatchTransferHost())
104 |
105 | self.enqueue(await writer.Notification(f"{slot.p.username} became host!"))
106 |
107 | await self.enqueue_state()
108 |
109 | async def enqueue_state(self, immune: set[int] = set(), lobby: bool = False) -> None:
110 | for p in self.connected:
111 | if p.id not in immune:
112 | p.enqueue(await writer.MatchUpdate(self))
113 |
114 | if lobby:
115 | chan = glob.channels.get_channel("#lobby")
116 | chan.enqueue(await writer.MatchUpdate(self))
117 |
118 | def enqueue(self, data, lobby: bool = False) -> None:
119 | for p in self.connected:
120 | p.enqueue(data)
121 |
122 | if lobby:
123 | chan = glob.channels.get_channel("#lobby")
124 | chan.enqueue(data)
125 |
--------------------------------------------------------------------------------
/objects/player.py:
--------------------------------------------------------------------------------
1 | from constants.player import PresenceFilter, bStatus, Privileges, country_codes
2 | from constants.match import SlotStatus
3 | from objects.channel import Channel
4 | from constants.levels import levels
5 | from constants.playmode import Mode
6 | from typing import TYPE_CHECKING
7 | from constants.mods import Mods
8 | from objects.match import Match
9 | from typing import Optional
10 | from packets import writer
11 | from objects import glob
12 | from utils import log
13 | from copy import copy
14 | import asyncio
15 | import aiohttp
16 | import time
17 | import uuid
18 |
19 | if TYPE_CHECKING:
20 | from objects.beatmap import Beatmap
21 | from objects.score import Score
22 |
23 |
24 | class Player:
25 | def __init__(
26 | self,
27 | username: str,
28 | id: int,
29 | privileges: int,
30 | passhash: str,
31 | lon: float = 0.0,
32 | lat: float = 0.0,
33 | country: str = "XX",
34 | country_code: int = 0,
35 | **kwargs,
36 | ) -> None:
37 | self.id: int = id
38 | self.username: str = username
39 | self.safe_name: str = self.safe_username(self.username)
40 | self.privileges: int = privileges
41 | self.passhash: str = passhash
42 |
43 | self.country_code: str = country
44 | self.country: str = country_code
45 |
46 | self.ip: str = kwargs.get("ip", "127.0.0.1")
47 | self.longitude: float = lon
48 | self.latitude: float = lat
49 | self.timezone: int = kwargs.get("time_offset", 0) + 24
50 | self.client_version: float = kwargs.get("version", 0.0)
51 | self.in_lobby: bool = False
52 |
53 | if kwargs.get("token"):
54 | self.token: str = kwargs.get("token")
55 | else:
56 | self.token: str = self.generate_token()
57 |
58 | self.presence_filter: PresenceFilter = PresenceFilter.NIL
59 |
60 | self.status: bStatus = bStatus.IDLE
61 | self.status_text: str = ""
62 | self.beatmap_md5: str = ""
63 | self.current_mods: Mods = Mods.NONE
64 | self.play_mode: Mode = Mode.OSU
65 | self.beatmap_id: int = -1
66 |
67 | self.friends: set[int] = set()
68 | self.channels: list[Channel] = []
69 | self.spectators: list[Player] = []
70 | self.spectating: Player = None
71 | self.match: Match = None
72 |
73 | self.ranked_score: int = 0
74 | self.accuracy: float = 0.0
75 | self.playcount: int = 0
76 | self.total_score: int = 0
77 | self.level: float = 0.0
78 | self.rank: int = 0
79 | self.pp: int = 0
80 |
81 | self.relax: int = 0 # 0 for vn / 1 for rx
82 |
83 | self.block_unknown_pms: bool = kwargs.get("block_nonfriend", False)
84 |
85 | self.queue: bytes = bytearray()
86 |
87 | self.login_time: float = time.time()
88 | self.last_update: float = 0.0
89 |
90 | self.bot: "Player" = False
91 |
92 | self.is_restricted: bool = not (self.privileges & Privileges.VERIFIED) and (
93 | not self.privileges & Privileges.PENDING
94 | )
95 | self.is_staff: bool = self.privileges & Privileges.BAT
96 |
97 | self.last_np: "Beatmap" = None
98 | self.last_score: "Score" = None
99 |
100 | @property
101 | def embed(self) -> str:
102 | return f"[https://osu.mitsuha.pw/users/{self.id} {self.username}]"
103 |
104 | @property
105 | def url(self) -> str:
106 | return f"https://osu.mitsuha.pw/users/{self.id}"
107 |
108 | @staticmethod
109 | def generate_token() -> str:
110 | return str(uuid.uuid4())
111 |
112 | def safe_username(self, name) -> str:
113 | return name.lower().replace(" ", "_")
114 |
115 | def enqueue(self, packet: bytes) -> None:
116 | self.queue += packet
117 |
118 | def dequeue(self) -> None:
119 | if self.queue:
120 | ret = bytes(self.queue)
121 | self.queue.clear()
122 | return ret
123 |
124 | async def shout(self, text: str):
125 | self.enqueue(await writer.Notification(text))
126 |
127 | async def logout(self) -> None:
128 | if self.channels:
129 | while self.channels:
130 | await self.leave_channel(self.channels[0], kicked=False)
131 |
132 | if self.match:
133 | await self.leave_match()
134 |
135 | if self.spectating:
136 | # leave spectating code and stuff idk
137 | ...
138 |
139 | glob.players.remove_user(self)
140 |
141 | for player in glob.players.players:
142 | if player != self:
143 | player.enqueue(await writer.Logout(self.id))
144 |
145 | async def add_spectator(self, p) -> None:
146 | # TODO: Create temp spec channel
147 | joined = await writer.FellasJoinSpec(p.id)
148 |
149 | for s in self.spectators:
150 | s.enqueue(joined)
151 | p.enqueue(await writer.FellasJoinSpec(s.id))
152 |
153 | self.enqueue(await writer.UsrJoinSpec(p.id))
154 | self.spectators.append(p)
155 |
156 | p.spectating = self
157 |
158 | async def remove_spectator(self, p) -> None:
159 | # TODO: Remove chan and part chan
160 | left = await writer.FellasLeftSpec(p.id)
161 |
162 | for s in self.spectators:
163 | s.enqueue(left)
164 |
165 | self.enqueue(await writer.UsrLeftSpec(p.id))
166 | self.spectators.remove(p)
167 |
168 | p.spectating = None
169 |
170 | async def join_match(self, m: Match, pwd: Optional[str] = "") -> None:
171 | if (
172 | self.match or
173 | pwd != m.match_pass or
174 | not m in glob.matches.matches
175 | ):
176 | self.enqueue(await writer.MatchFail())
177 | return # user is already in a match
178 |
179 | if (free_slot := m.get_free_slot()) is None:
180 | self.enqueue(await writer.MatchFail())
181 | log.warn(f"{self.username} tried to join a full match ({m!r})")
182 | return
183 |
184 | self.match = m
185 |
186 | slot = m.slots[free_slot]
187 |
188 | slot.p = self
189 | slot.mods = 0
190 | slot.status = SlotStatus.NOTREADY
191 |
192 | if m.host == self.id:
193 | slot.host = True
194 |
195 | if not self.match.chat:
196 | mc = Channel(**{"raw": f"#multi_{self.match.match_id}", "name": "#multiplayer", "description": self.match.match_name})
197 | self.match.chat = mc
198 |
199 | await self.join_channel(self.match.chat)
200 |
201 | self.match.connected.append(self)
202 |
203 | self.enqueue(await writer.MatchJoin(self.match)) # join success
204 |
205 | log.info(f"{self.username} joined {m}")
206 | await self.match.enqueue_state(lobby=True)
207 |
208 | async def leave_match(self) -> None:
209 | if (
210 | not self.match or
211 | not (slot := self.match.find_user(self))
212 | ):
213 | return
214 |
215 | await self.leave_channel(self.match.chat)
216 |
217 | m = copy(self.match)
218 | self.match = None
219 |
220 | slot.reset()
221 | m.connected.remove(self)
222 |
223 | log.info(f"{self.username} left {m}")
224 |
225 | # if that was the last person
226 | # to leave the multiplayer
227 | # delete the multi lobby
228 | if not m.connected:
229 | log.info(f"{m} is empty! Removing...")
230 |
231 | m.enqueue(await writer.MatchDispose(m.match_id), lobby=True)
232 |
233 | await glob.matches.remove_match(m)
234 | return
235 |
236 | if m.host == self.id:
237 | log.info("Host left, rotating host.")
238 | for slot in m.slots:
239 | if not slot.host and slot.status & SlotStatus.OCCUPIED:
240 | await m.transfer_host(slot)
241 |
242 | break
243 |
244 | await m.enqueue_state(immune={self.id}, lobby=True)
245 |
246 | async def join_channel(self, chan: Channel):
247 | if (
248 | chan in self.channels
249 | or (chan.staff # if the chan is already in the user lists chans
250 | and not self.is_staff) # if the user isnt staff and the chan is.
251 | ):
252 | return
253 |
254 | self.channels.append(chan)
255 | chan.connected.append(self)
256 |
257 | self.enqueue(await writer.ChanJoin(chan.name))
258 |
259 | await chan.update_info()
260 |
261 | async def leave_channel(self, chan: Channel, kicked: bool = True):
262 | if not chan in self.channels:
263 | return
264 |
265 | self.channels.remove(chan)
266 | chan.connected.remove(self)
267 |
268 | if kicked:
269 | self.enqueue(await writer.ChanKick(chan.name))
270 |
271 | await chan.update_info()
272 |
273 | async def send_message(self, message, reciever: "Player" = None):
274 | reciever.enqueue(
275 | await writer.SendMessage(
276 | sender=self.username,
277 | message=message,
278 | channel=reciever.username,
279 | id=self.id,
280 | )
281 | )
282 |
283 | async def get_friends(self) -> None:
284 | async for player in glob.sql.iterall(
285 | "SELECT user_id2 as id FROM friends WHERE user_id1 = %s", (self.id)
286 | ):
287 | self.friends.add(player["id"])
288 |
289 | async def handle_friend(self, user: int) -> None:
290 | if not (t := glob.players.get_user(user)):
291 | return # user isn't online; ignore
292 |
293 | # remove friend
294 | if await glob.sql.fetch(
295 | "SELECT 1 FROM friends WHERE user_id1 = %s AND user_id2 = %s",
296 | (self.id, user),
297 | ):
298 | await glob.sql.execute(
299 | "DELETE FROM friends WHERE user_id1 = %s AND user_id2 = %s",
300 | (self.id, user),
301 | )
302 | self.friends.remove(user)
303 |
304 | log.info(f"{self.username} removed {t.username} as friends.")
305 | return
306 |
307 | # add friend
308 | await glob.sql.execute(
309 | "INSERT INTO friends (user_id1, user_id2) VALUES (%s, %s)", (self.id, user)
310 | )
311 | self.friends.add(user)
312 |
313 | log.info(f"{self.username} added {t.username} as friends.")
314 |
315 | async def restrict(self) -> None:
316 | if self.is_restricted:
317 | return # just ignore if the user
318 | # is already restricted.
319 |
320 | self.privileges -= Privileges.VERIFIED
321 |
322 | asyncio.create_task(
323 | glob.db.execute("UPDATE users SET privileges -= 4 WHERE id = %s", (self.id))
324 | )
325 |
326 | # notify user
327 | await self.shout("Your account has been put in restricted mode!")
328 |
329 | log.info(f"{self.username} has been put in restricted mode!")
330 |
331 | async def update_stats(self, mode=None, relax=None) -> None:
332 | if (m := mode) is None:
333 | m = self.play_mode
334 |
335 | if (rx := relax) is None:
336 | rx = self.relax
337 |
338 | spec_tables = ("stats", "stats_rx")[rx]
339 | se = ("std", "taiko", "catch", "mania")[m]
340 |
341 | self.get_level()
342 |
343 | await glob.sql.execute(
344 | f"UPDATE {spec_tables} SET pp_{se} = %s, playcount_{se} = %s, "
345 | f"accuracy_{se} = %s, total_score_{se} = %s, "
346 | f"ranked_score_{se} = %s, level_{se} = %s WHERE id = %s",
347 | (
348 | self.pp,
349 | self.playcount,
350 | round(self.accuracy, 2),
351 | self.total_score,
352 | self.ranked_score,
353 | self.level,
354 | self.id,
355 | ),
356 | )
357 |
358 | def get_level(self):
359 | for idx, req_score in enumerate(levels):
360 | if req_score < self.total_score < levels[idx + 1]:
361 | self.level = idx + 1
362 |
363 | # used for background tasks
364 | async def check_loc(self):
365 | lon, lat, cc, c = await self.set_location(get=True)
366 |
367 | if lon != self.longitude:
368 | self.longitude = lon
369 |
370 | if lat != self.latitude:
371 | self.latitude = lat
372 |
373 | if c != self.country_code:
374 | self.country_code = c
375 |
376 | if cc != self.country:
377 | self.country = cc
378 |
379 | await self.save_location()
380 |
381 | async def set_location(self, get: bool = False):
382 | async with aiohttp.ClientSession() as sess:
383 | async with sess.get(
384 | f"http://ip-api.com/json/{self.ip}?fields=status,message,countryCode,region,lat,lon"
385 | ) as resp:
386 | if not (ret := await resp.json()):
387 | return # sus
388 |
389 | if ret["status"] == "fail":
390 | log.fail(
391 | f"Unable to get {self.username}'s location. Response: {ret['message']}"
392 | )
393 | return
394 |
395 | if not get:
396 | self.latitude = ret["lat"]
397 | self.longitude = ret["lon"]
398 | self.country = country_codes[ret["countryCode"]]
399 | self.country_code = ret["countryCode"]
400 |
401 | return
402 |
403 | return (
404 | ret["lat"],
405 | ret["lon"],
406 | country_codes[ret["countryCode"]],
407 | ret["countryCode"],
408 | )
409 |
410 | async def save_location(self):
411 | await glob.sql.execute(
412 | "UPDATE users SET lon = %s, lat = %s, country = %s, cc = %s WHERE id = %s",
413 | (self.longitude, self.latitude, self.country_code, self.country, self.id),
414 | )
415 |
416 | async def get_stats(self, relax: int = 0, mode: int = 0) -> dict:
417 | table = ("stats", "stats_rx")[relax]
418 | se = ("std", "taiko", "catch", "mania")[mode]
419 |
420 | ret = await glob.sql.fetch(
421 | f"SELECT ranked_score_{se} AS ranked_score, "
422 | f"total_score_{se} AS total_score, accuracy_{se} AS accuracy, "
423 | f"playcount_{se} AS playcount, pp_{se} AS pp, "
424 | f"level_{se} AS level FROM {table} "
425 | "WHERE id = %s",
426 | (self.id),
427 | )
428 |
429 | if ret["pp"] >= 1:
430 | # if the users pp is
431 | # higher or equal to
432 | # one, add rank to the user
433 | rank = await glob.sql.fetch(
434 | f"SELECT COUNT(*) AS rank FROM {table} t "
435 | "INNER JOIN users u ON u.id = t.id "
436 | f"WHERE t.id != %s AND t.pp_{se} > %s "
437 | f"ORDER BY t.pp_{se} DESC, t.total_score_{se} DESC LIMIT 1",
438 | (self.id, self.pp),
439 | )
440 |
441 | ret["rank"] = rank["rank"] + 1
442 | else:
443 | # if not, make the user
444 | # not display any rank. (0)
445 | ret["rank"] = 0
446 |
447 | return ret
448 |
449 | async def update_stats_cache(self) -> bool:
450 | ret = await self.get_stats(self.relax, self.play_mode)
451 |
452 | self.ranked_score = ret["ranked_score"]
453 | self.accuracy = ret["accuracy"]
454 | self.playcount = ret["playcount"]
455 | self.total_score = ret["total_score"]
456 | self.level = ret["level"]
457 | self.rank = ret["rank"]
458 | self.pp = int(ret["pp"])
459 |
460 | return True
461 |
--------------------------------------------------------------------------------
/objects/score.py:
--------------------------------------------------------------------------------
1 | from py3rijndael.rijndael import RijndaelCbc
2 | from py3rijndael.paddings import ZeroPadding
3 | from constants.beatmap import Approved
4 | from objects.beatmap import Beatmap
5 | from constants.playmode import Mode
6 | from constants.playmode import Mode
7 | from dataclasses import dataclass
8 | from objects.player import Player
9 | from constants.mods import Mods
10 | from base64 import b64decode
11 | from enum import IntEnum
12 | from objects import glob
13 | from utils import score
14 | import oppai as pp
15 | import math
16 | import time
17 |
18 |
19 | @dataclass
20 | class ScoreFrame:
21 | time: int = 0
22 | id: int = 0
23 |
24 | count_300: int = 0
25 | count_100: int = 0
26 | count_50: int = 0
27 |
28 | count_geki: int = 0
29 | count_katu: int = 0
30 | count_miss: int = 0
31 |
32 | score: int = 0
33 | max_combo: int = 0
34 | combo: int = 0
35 |
36 | perfect: bool = False
37 |
38 | current_hp: int = 0
39 | tag_byte: int = 0
40 |
41 | score_v2: bool = False
42 |
43 |
44 | class SubmitStatus(IntEnum):
45 | FAILED = 0
46 | QUIT = 1
47 | PASSED = 2
48 | BEST = 3
49 |
50 |
51 | class Score:
52 | def __init__(self):
53 | self.player: Player = None
54 | self.map: Beatmap = None
55 |
56 | self.id: int = 0
57 |
58 | self.score: int = 0
59 | self.pp: float = 0.0
60 |
61 | self.count_300: int = 0
62 | self.count_100: int = 0
63 | self.count_50: int = 0
64 |
65 | self.count_geki: int = 0
66 | self.count_katu: int = 0
67 | self.count_miss: int = 0
68 |
69 | self.max_combo: int = 0
70 | self.accuracy: float = 0.0
71 |
72 | self.perfect: bool = False
73 |
74 | self.rank: str = ""
75 |
76 | self.mods: int = 0
77 | self.status: SubmitStatus = SubmitStatus.FAILED
78 |
79 | self.play_time: int = 0
80 |
81 | self.mode: Mode = Mode.OSU
82 |
83 | self.submitted: int = math.ceil(time.time())
84 |
85 | self.relax: bool = False
86 |
87 | self.position: int = 0
88 |
89 | # previous_best
90 | self.pb: "Score" = None
91 |
92 | @property
93 | def web_format(self) -> str:
94 | return (
95 | f"\n{self.id}|{self.player.username}|{self.score if not self.relax else math.ceil(self.pp)}|"
96 | f"{self.max_combo}|{self.count_50}|{self.count_100}|{self.count_300}|{self.count_miss}|"
97 | f"{self.count_katu}|{self.count_geki}|{self.perfect}|{self.mods}|{self.player.id}|"
98 | f"{self.position}|{self.submitted}|1"
99 | )
100 |
101 | @classmethod
102 | async def set_data_from_sql(cls, score_id: int) -> "Score":
103 | data = await glob.sql.fetch(
104 | "SELECT id, user_id, hash_md5, score, pp, count_300, count_100, "
105 | "count_50, count_geki, count_katu, count_miss, "
106 | "max_combo, accuracy, perfect, rank, mods, status, "
107 | "play_time, mode, submitted, relax FROM scores "
108 | "WHERE id = %s",
109 | (score_id),
110 | )
111 |
112 | s = cls()
113 |
114 | s.player = await glob.players.get_user_offline(data["user_id"])
115 | s.map = await Beatmap.get_beatmap(data["hash_md5"])
116 |
117 | s.score = data["score"]
118 | s.pp = data["pp"]
119 |
120 | s.count_300 = data["count_300"]
121 | s.count_100 = data["count_100"]
122 | s.count_50 = data["count_50"]
123 | s.count_geki = data["count_geki"]
124 | s.count_katu = data["count_katu"]
125 | s.count_miss = data["count_miss"]
126 |
127 | s.max_combo = data["max_combo"]
128 | s.accuracy = data["accuracy"]
129 |
130 | s.perfect = data["perfect"]
131 |
132 | s.rank = data["rank"]
133 | s.mods = data["mods"]
134 |
135 | s.play_time = data["play_time"]
136 |
137 | s.status = SubmitStatus(data["status"])
138 | s.mode = Mode(data["mode"])
139 |
140 | s.submitted = data["submitted"]
141 |
142 | s.relax = data["relax"]
143 |
144 | await s.calculate_position()
145 |
146 | return s
147 |
148 | @classmethod
149 | async def set_data_from_submission(
150 | cls, score_enc: bytes, iv: bytes, key: str, exited: int
151 | ) -> "Score":
152 | score_latin = b64decode(score_enc).decode("latin_1")
153 | iv_latin = b64decode(iv).decode("latin_1")
154 |
155 | data = (
156 | RijndaelCbc(key, iv_latin, ZeroPadding(32), 32)
157 | .decrypt(score_latin)
158 | .decode()
159 | .split(":")
160 | )
161 |
162 | s = cls()
163 |
164 | s.player = glob.players.get_user(data[1].rstrip())
165 |
166 | if not s.player:
167 | return
168 |
169 | if data[0] in glob.beatmaps:
170 | s.map = glob.beatmaps[data[0]]
171 | else:
172 | s.map = await Beatmap.get_beatmap(data[0])
173 |
174 | (
175 | s.count_300,
176 | s.count_100,
177 | s.count_50,
178 | s.count_geki,
179 | s.count_katu,
180 | s.count_miss,
181 | s.score,
182 | s.max_combo,
183 | ) = map(int, data[3:-7])
184 |
185 | s.mode = Mode(int(data[15]))
186 |
187 | s.accuracy = score.calculate_accuracy(
188 | s.mode,
189 | s.count_300,
190 | s.count_100,
191 | s.count_50,
192 | s.count_geki,
193 | s.count_katu,
194 | s.count_miss,
195 | )
196 |
197 | s.perfect = s.max_combo == s.map.max_combo
198 |
199 | s.rank = data[12]
200 |
201 | s.mods = int(data[13])
202 | passed = data[14] == "True"
203 |
204 | if exited:
205 | s.status = SubmitStatus.QUIT
206 |
207 | s.relax = bool(int(data[13]) & Mods.RELAX)
208 |
209 | if passed:
210 | await s.calculate_position()
211 |
212 | if Approved(s.map.approved - 1) not in (
213 | Approved.LOVED,
214 | Approved.PENDING,
215 | Approved.WIP,
216 | Approved.GRAVEYARD,
217 | ):
218 | ez = pp.ezpp_new()
219 |
220 | if s.mods:
221 | pp.ezpp_set_mods(ez, s.mods)
222 |
223 | pp.ezpp_set_combo(ez, s.max_combo)
224 | pp.ezpp_set_nmiss(ez, s.count_miss)
225 | pp.ezpp_set_accuracy_percent(ez, s.accuracy)
226 |
227 | pp.ezpp(ez, f".data/beatmaps/{s.map.file}")
228 | s.pp = pp.ezpp_pp(ez)
229 |
230 | pp.ezpp_free(ez)
231 |
232 | # find our previous best score on the map
233 | if prev_best := await glob.sql.fetch(
234 | "SELECT id FROM scores WHERE user_id = %s "
235 | "AND relax = %s AND hash_md5 = %s "
236 | "AND mode = %s AND status = 3 LIMIT 1",
237 | (s.player.id, s.relax, s.map.hash_md5, s.mode.value),
238 | ):
239 | s.pb = await Score.set_data_from_sql(prev_best["id"])
240 |
241 | # if we found a personal best score
242 | # that has more score on the map,
243 | # we set it to passed.
244 | if s.pb.pp < s.pp if s.relax else s.pb.score < s.score:
245 | s.status = SubmitStatus.BEST
246 | s.pb.status = SubmitStatus.PASSED
247 |
248 | await glob.sql.execute(
249 | "UPDATE scores SET status = 2 WHERE user_id = %s AND relax = %s "
250 | "AND hash_md5 = %s AND mode = %s AND status = 3",
251 | (s.player.id, s.relax, s.map.hash_md5, s.mode.value),
252 | )
253 | else:
254 | s.status = SubmitStatus.PASSED
255 | else:
256 | # if we find no old personal best
257 | # we can just set the status to best
258 | s.status = SubmitStatus.BEST
259 | else:
260 | s.status = SubmitStatus.FAILED
261 |
262 | # Currently all I need for this checksum
263 | # to work, is a storyboard checksum? Yeah,
264 | # I don't know either. I KNOW, nvm.
265 |
266 | # security_hash = RijndaelCbc(key, iv_latin, ZeroPadding(32), 32).decrypt(b64decode(security_hash).decode("latin_1")).decode()
267 | # reci_check_sum = data[2]
268 |
269 | # check_sum = md5(
270 | # f"chickenmcnuggets"
271 | # f"{s.count_100 + s.count_300}o15{s.count_50}{s.count_geki}"
272 | # f"smustard{s.count_katu}{s.count_miss}uu"
273 | # f"{s.map.hash_md5}{s.max_combo}{str(s.perfect)}"
274 | # f"{s.player.username}{s.score}{s.rank}{s.mods}Q{str(s.passed)}"
275 | # f"{s.mode}{data[17].strip()}{data[16]}{security_hash}{storyboardchecksum}"
276 | # .encode()
277 | # ).hexdigest()
278 |
279 | # if reci_check_sum != check_sum:
280 | # log.error(f"{s.player.username} tried to submit a score with an invalid score checksum.")
281 | # return
282 |
283 | return s
284 |
285 | async def calculate_position(self) -> None:
286 | ret = await glob.sql.fetch(
287 | "SELECT COUNT(*) AS rank FROM scores s "
288 | "INNER JOIN beatmaps b ON b.hash = s.hash_md5 "
289 | "INNER JOIN users u ON u.id = s.user_id "
290 | "WHERE s.score > %s AND s.relax = %s "
291 | "AND b.hash = %s AND u.privileges & 4 "
292 | "AND s.status = 3 AND s.mode = %s "
293 | "ORDER BY s.score DESC, s.submitted DESC",
294 | (self.score, self.relax, self.map.hash_md5, self.mode.value),
295 | )
296 |
297 | self.position = ret["rank"] + 1
298 |
299 | async def save_to_db(self) -> None:
300 | await glob.sql.execute(
301 | "INSERT INTO scores (hash_md5, user_id, score, pp, "
302 | "count_300, count_100, count_50, count_geki, "
303 | "count_katu, count_miss, max_combo, accuracy, "
304 | "perfect, rank, mods, status, play_time, "
305 | " mode, submitted, relax) VALUES "
306 | "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, "
307 | "%s, %s, %s, %s, %s, %s, %s)",
308 | (
309 | self.map.hash_md5,
310 | self.player.id,
311 | self.score,
312 | self.pp,
313 | self.count_300,
314 | self.count_100,
315 | self.count_50,
316 | self.count_geki,
317 | self.count_katu,
318 | self.count_miss,
319 | self.max_combo,
320 | self.accuracy,
321 | self.perfect,
322 | self.rank,
323 | self.mods,
324 | self.status.value,
325 | self.play_time,
326 | self.mode.value,
327 | self.submitted,
328 | self.relax,
329 | ),
330 | )
331 |
--------------------------------------------------------------------------------
/packets/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osumitsuha/Ragnarok/948214e20d4669ad1fe22416552665174f70363c/packets/__init__.py
--------------------------------------------------------------------------------
/packets/reader.py:
--------------------------------------------------------------------------------
1 | from typing import AnyStr, Callable, Iterator
2 | from constants.packets import BanchoPackets
3 | from objects.score import ScoreFrame
4 | from constants.playmode import Mode
5 | from dataclasses import dataclass
6 | from objects.match import Match
7 | from constants.mods import Mods
8 | from constants.match import *
9 | from objects import glob
10 | from utils import log
11 | import struct
12 |
13 | IGNORED_PACKETS = [4, 79]
14 |
15 |
16 | @dataclass
17 | class Packet:
18 | packet: BanchoPackets
19 |
20 | callback: Callable
21 | restricted: bool
22 |
23 |
24 | class Reader:
25 | def __init__(self, packet_data: bytes):
26 | self.packet_data = memoryview(packet_data)
27 | self.offset = 0
28 | self.packet, self.plen = None, 0
29 |
30 | def __iter__(self) -> Iterator:
31 | return self
32 |
33 | def __next__(self) -> Packet:
34 | while self.data:
35 | self.packet, self.plen = self.read_headers()
36 |
37 | if self.packet not in glob.packets and (not self.packet > 109):
38 | if glob.debug and self.packet not in IGNORED_PACKETS:
39 | log.warn(
40 | f"Packet <{BanchoPackets(self.packet)} | {BanchoPackets(self.packet).name}> has been requested although it's an unregistered packet."
41 | )
42 |
43 | if self.plen != 0:
44 | self.offset += self.plen
45 | else:
46 | break
47 | else:
48 | raise StopIteration
49 |
50 | self.packet = BanchoPackets(self.packet)
51 |
52 | return glob.packets[self.packet.value]
53 |
54 | def read_headers(self) -> tuple[BanchoPackets, int]:
55 | if len(self.data) < 7:
56 | raise StopIteration
57 |
58 | ret = struct.unpack(" int:
67 | ret = struct.unpack(" int:
72 | ret = struct.unpack(" int:
77 | ret = int.from_bytes(self.data[:1], "little", signed=True)
78 | self.offset += 1
79 | return ret - 256 if ret > 127 else ret
80 |
81 | def read_uint8(self) -> int:
82 | ret = int.from_bytes(self.data[:1], "little", signed=False)
83 | self.offset += 1
84 | return ret
85 |
86 | def read_int16(self) -> int:
87 | ret = int.from_bytes(self.data[:2], "little", signed=True)
88 | self.offset += 2
89 | return ret
90 |
91 | def read_uint16(self) -> int:
92 | ret = int.from_bytes(self.data[:2], "little", signed=False)
93 | self.offset += 2
94 | return ret
95 |
96 | def read_int32(self) -> int:
97 | ret = int.from_bytes(self.data[:4], "little", signed=True)
98 | self.offset += 4
99 | return ret
100 |
101 | def read_uint32(self) -> int:
102 | ret = int.from_bytes(self.data[:4], "little", signed=False)
103 | self.offset += 4
104 | return ret
105 |
106 | def read_int64(self) -> int:
107 | ret = int.from_bytes(self.data[:8], "little", signed=True)
108 | self.offset += 8
109 | return ret
110 |
111 | def read_uint64(self) -> int:
112 | ret = int.from_bytes(self.data[:8], "little", signed=False)
113 | self.offset += 8
114 | return ret
115 |
116 | def read_i32_list(self) -> tuple[int]:
117 | length = self.read_int16()
118 |
119 | ret = struct.unpack(f"<{'I' * length}", self.data[: length * 4])
120 |
121 | self.offset += length * 4
122 | return ret
123 |
124 | def read_float32(self) -> float:
125 | ret = struct.unpack(" float:
130 | ret = struct.unpack(" str:
135 | self.offset += 1
136 |
137 | shift = 0
138 | result = 0
139 |
140 | while True:
141 | b = self.data[0]
142 | self.offset += 1
143 |
144 | result |= (b & 0x7F) << shift
145 |
146 | if b & 0x80 == 0:
147 | break
148 |
149 | shift += 7
150 |
151 | ret = self.data[:result].tobytes().decode()
152 |
153 | self.offset += result
154 | return ret
155 |
156 | def _read_raw(self, length: int) -> AnyStr:
157 | ret = self.data[:length]
158 | self.offset += length
159 | return ret
160 |
161 | def read_raw(self) -> AnyStr:
162 | ret = self.data[: self.plen]
163 | self.offset += self.plen
164 | return ret
165 |
166 | def read_match(self) -> Match:
167 | # m = Match()
168 |
169 | # m.match_id = len(glob.matches.matches)
170 |
171 | # # self.offset += 2
172 | # self.read_uint16()
173 |
174 | # m.in_progress = bool(self.read_int8())
175 |
176 | # self.read_byte() # ignore match type; 0 = normal osu!, 1 = osu! arcade
177 |
178 | # m.mods = Mods(self.read_uint32())
179 |
180 | # m.match_name = self.read_str()
181 | # m.match_pass = self.read_str()
182 |
183 | # m.map_title = self.read_str()
184 | # m.map_id = self.read_int32()
185 | # m.map_md5 = self.read_str()
186 |
187 | # for slot in m.slots:
188 | # slot.status = SlotStatus(self.read_int8())
189 |
190 | # for slot in m.slots:
191 | # slot.team = SlotTeams(self.read_int8())
192 |
193 | # for slot in m.slots:
194 | # if slot.status & SlotStatus.OCCUPIED:
195 | # # self.offset += 4
196 | # self.read_int32()
197 |
198 | # m.host = self.read_int32()
199 |
200 | # m.mode = Mode(self.read_byte())
201 | # m.scoring_type = ScoringType(self.read_byte())
202 | # m.team_type = TeamType(self.read_byte())
203 |
204 | # m.freemods = self.read_byte() == 1
205 |
206 | # if m.freemods:
207 | # for slot in m.slots:
208 | # slot.mods = Mods(self.read_int32())
209 |
210 | # m.seed = self.read_int32()
211 |
212 | # return m
213 | m = Match()
214 |
215 | m.match_id = len(glob.matches.matches)
216 |
217 | self.offset += 2
218 |
219 | m.in_progress = self.read_int8()
220 |
221 | self.read_int8() # ignore match type; 0 = normal osu!, 1 = osu! arcade
222 |
223 | m.mods = Mods(self.read_int32())
224 |
225 | m.match_name = self.read_str()
226 | m.match_pass = self.read_str()
227 |
228 | m.map_title = self.read_str()
229 | m.map_id = self.read_int32()
230 | m.map_md5 = self.read_str()
231 |
232 | for slot in m.slots:
233 | slot.status = SlotStatus(self.read_int8())
234 |
235 | for slot in m.slots:
236 | slot.team = SlotTeams(self.read_int8())
237 |
238 | for slot in m.slots:
239 | if slot.status & SlotStatus.OCCUPIED:
240 | self.offset += 4
241 |
242 | m.host = self.read_int32()
243 |
244 | m.mode = Mode(self.read_int8())
245 | m.scoring_type = ScoringType(self.read_int8())
246 | m.team_type = TeamType(self.read_int8())
247 |
248 | m.freemods = self.read_int8() == 1
249 |
250 | if m.freemods:
251 | for slot in m.slots:
252 | slot.mods = Mods(self.read_int32())
253 |
254 | m.seed = self.read_int32()
255 |
256 | return m
257 |
258 | def read_scoreframe(self) -> ScoreFrame:
259 | s = ScoreFrame()
260 |
261 | s.time = self.read_int32()
262 | s.id = self.read_byte()
263 |
264 | s.count_300 = self.read_uint16()
265 | s.count_100 = self.read_uint16()
266 | s.count_50 = self.read_uint16()
267 | s.count_geki = self.read_uint16()
268 | s.count_katu = self.read_uint16()
269 | s.count_miss = self.read_uint16()
270 |
271 | s.score = self.read_int32()
272 |
273 | s.max_combo = self.read_uint16()
274 | s.combo = self.read_uint16()
275 |
276 | s.perfect = self.read_int8()
277 |
278 | s.current_hp = self.read_byte()
279 | s.tag_byte = self.read_byte()
280 |
281 | s.score_v2 = self.read_int8()
282 |
283 | return s
284 |
--------------------------------------------------------------------------------
/packets/writer.py:
--------------------------------------------------------------------------------
1 | from constants.player import Ranks, Privileges
2 | from constants.packets import BanchoPackets
3 | from constants.match import SlotStatus
4 | from typing import Any, TYPE_CHECKING
5 | from enum import unique, IntEnum
6 | from objects import glob
7 | import struct
8 | import math
9 |
10 | if TYPE_CHECKING:
11 | from objects.match import Match
12 | from objects.player import Player
13 | from objects.score import ScoreFrame
14 |
15 | spec = (" bytearray:
47 | if value == 0:
48 | return bytearray(b"\x00")
49 |
50 | data: bytearray = bytearray()
51 | length: int = 0
52 |
53 | while value > 0:
54 | data.append(value & 0x7F)
55 | value >>= 7
56 | if value != 0:
57 | data[length] |= 0x80
58 |
59 | length += 1
60 |
61 | return data
62 |
63 |
64 | async def write_byte(value: int) -> bytearray:
65 | return bytearray(struct.pack(" bytearray:
69 | return bytearray(struct.pack(" bytearray:
73 | return bytearray(value.to_bytes(Types.int32, "little", signed=True))
74 |
75 |
76 | async def write_int32_list(values: tuple[int]) -> bytearray:
77 | data = bytearray(len(values).to_bytes(2, "little"))
78 |
79 | for value in values:
80 | data += value.to_bytes(4, "little")
81 |
82 | return data
83 |
84 |
85 | async def write_multislots(slots) -> bytearray:
86 | ret = bytearray()
87 |
88 | ret.extend([s.status for s in slots])
89 | ret.extend([s.team for s in slots])
90 |
91 | for slot in slots:
92 | if slot.status & SlotStatus.OCCUPIED:
93 | ret += slot.p.id.to_bytes(4, "little")
94 |
95 | return ret
96 |
97 |
98 | async def write_multislotsmods(slots) -> bytearray:
99 | ret = bytearray()
100 |
101 | for slot in slots:
102 | ret += slot.mods.to_bytes(4, "little")
103 |
104 | return ret
105 |
106 |
107 | async def write_str(string: str) -> bytearray:
108 | if not string:
109 | return bytearray(b"\x00")
110 |
111 | data = bytearray(b"\x0B")
112 |
113 | data += await write_uleb128(len(string.encode()))
114 | data += string.encode()
115 | return data
116 |
117 |
118 | async def write_msg(sender: str, msg: str, chan: str, id: int) -> bytearray:
119 | ret = bytearray()
120 |
121 | ret += await write_str(sender)
122 | ret += await write_str(msg)
123 | ret += await write_str(chan)
124 | ret += id.to_bytes(4, "little", signed=True)
125 |
126 | return ret
127 |
128 |
129 | async def write(pID: int, *args: tuple[Any, ...]) -> bytes:
130 | data = bytearray(struct.pack(" bytes:
159 | """
160 | ID Responses:
161 | -1: Authentication Failure
162 | -2: Old Client
163 | -3: Banned (due to breaking the game rules)
164 | -4: Banned (due to account deactivation)
165 | -5: An error occurred
166 | -6: Needs Supporter
167 | -7: Password Reset
168 | -8: Requires Verification
169 | > -1: Valid ID
170 | """
171 | return await write(BanchoPackets.CHO_USER_ID, (id, Types.int32))
172 |
173 |
174 | async def UsrJoinSpec(id: int) -> bytes:
175 | return await write(BanchoPackets.CHO_SPECTATOR_JOINED, (id, Types.int32))
176 |
177 |
178 | async def UsrLeftSpec(id: int) -> bytes:
179 | return await write(BanchoPackets.CHO_SPECTATOR_LEFT, (id, Types.int32))
180 |
181 |
182 | async def FellasJoinSpec(id: int) -> bytes:
183 | return await write(BanchoPackets.CHO_FELLOW_SPECTATOR_JOINED, (id, Types.int32))
184 |
185 |
186 | async def FellasLeftSpec(id: int) -> bytes:
187 | return await write(BanchoPackets.CHO_FELLOW_SPECTATOR_LEFT, (id, Types.int32))
188 |
189 |
190 | async def UsrCantSpec(id: int) -> bytes:
191 | return await write(BanchoPackets.CHO_SPECTATOR_CANT_SPECTATE, (id, Types.int32))
192 |
193 |
194 | async def Notification(msg: str) -> bytes:
195 | return await write(BanchoPackets.CHO_NOTIFICATION, (msg, Types.string))
196 |
197 |
198 | async def UserPriv(privileges: int) -> bytes:
199 | rank = Ranks.NORMAL
200 | rank |= Ranks.SUPPORTER
201 |
202 | if privileges & Privileges.BAT:
203 | rank |= Ranks.BAT
204 |
205 | if privileges & Privileges.MODERATOR:
206 | rank |= Ranks.FRIEND
207 |
208 | if privileges & Privileges.ADMIN:
209 | rank |= Ranks.FRIEND
210 |
211 | if privileges & Privileges.DEV:
212 | rank |= Ranks.PEPPY
213 |
214 | return await write(BanchoPackets.CHO_PRIVILEGES, (rank, Types.int32))
215 |
216 |
217 | async def ProtocolVersion(version: int) -> bytes:
218 | return await write(BanchoPackets.CHO_PROTOCOL_VERSION, (version, Types.int32))
219 |
220 |
221 | async def UpdateFriends(friends_id: tuple[int]):
222 | return await write(BanchoPackets.CHO_FRIENDS_LIST, (friends_id, Types.int32_list))
223 |
224 |
225 | async def UpdateStats(p: "Player") -> bytes:
226 | if p not in glob.players.players:
227 | return b""
228 |
229 | return await write(
230 | BanchoPackets.CHO_USER_STATS,
231 | (p.id, Types.int32),
232 | (p.status.value, Types.uint8),
233 | (p.status_text, Types.string),
234 | (p.beatmap_md5, Types.string),
235 | (p.current_mods, Types.int32),
236 | (p.play_mode, Types.uint8),
237 | (p.beatmap_id, Types.int32),
238 | (p.ranked_score, Types.int64),
239 | (p.accuracy / 100.0, Types.float32),
240 | (p.playcount, Types.int32),
241 | (p.total_score, Types.int64),
242 | (p.rank, Types.int32),
243 | (math.ceil(p.pp), Types.int16),
244 | )
245 |
246 |
247 | async def UserPresence(p: "Player") -> bytes:
248 | if p not in glob.players.players:
249 | return b""
250 |
251 | rank = Ranks.NONE
252 |
253 | if p.privileges & Privileges.VERIFIED:
254 | rank |= Ranks.NORMAL
255 |
256 | if p.privileges & Privileges.BAT:
257 | rank |= Ranks.BAT
258 |
259 | if p.privileges & Privileges.SUPPORTER:
260 | rank |= Ranks.SUPPORTER
261 |
262 | if p.privileges & Privileges.MODERATOR:
263 | rank |= Ranks.FRIEND
264 |
265 | if p.privileges & Privileges.ADMIN:
266 | rank |= Ranks.FRIEND
267 |
268 | if p.privileges & Privileges.DEV:
269 | rank |= Ranks.PEPPY
270 |
271 | return await write(
272 | BanchoPackets.CHO_USER_PRESENCE,
273 | (p.id, Types.int32),
274 | (p.username, Types.string),
275 | (p.timezone, Types.byte),
276 | (p.country, Types.ubyte),
277 | (rank, Types.byte),
278 | (p.longitude, Types.float32),
279 | (p.latitude, Types.float32),
280 | (p.rank, Types.int32),
281 | )
282 |
283 |
284 | async def MainMenuIcon() -> bytes:
285 | return await write(
286 | BanchoPackets.CHO_MAIN_MENU_ICON,
287 | ("https://imgur.com/Uihzw6N.png|https://c.mitsuha.pw", Types.string),
288 | )
289 |
290 |
291 | async def ChanJoin(name: str) -> bytes:
292 | return await write(BanchoPackets.CHO_CHANNEL_JOIN_SUCCESS, (name, Types.string))
293 |
294 |
295 | async def ChanKick(name: str) -> bytes:
296 | return await write(BanchoPackets.CHO_CHANNEL_KICK, (name, Types.string))
297 |
298 |
299 | async def ChanAutoJoin(chan: str) -> bytes:
300 | return await write(BanchoPackets.CHO_CHANNEL_AUTO_JOIN, (chan, Types.string))
301 |
302 |
303 | async def ChanInfo(name: str) -> bytes:
304 | if not (c := glob.channels.get_channel(name)):
305 | return bytes()
306 |
307 | return await write(
308 | BanchoPackets.CHO_CHANNEL_INFO,
309 | (c.name, Types.string),
310 | (c.description, Types.string),
311 | (len(c.connected), Types.int32),
312 | )
313 |
314 |
315 | async def ChanInfoEnd() -> bytes:
316 | return await write(BanchoPackets.CHO_CHANNEL_INFO_END)
317 |
318 |
319 | async def ServerRestart() -> bytes:
320 | return await write(BanchoPackets.CHO_RESTART, (0, Types.int32))
321 |
322 |
323 | async def SendMessage(sender: str, message: str, channel: str, id: int) -> bytes:
324 | return await write(
325 | BanchoPackets.CHO_SEND_MESSAGE,
326 | ((sender, message, channel, id), Types.message),
327 | )
328 |
329 |
330 | async def Logout(id: int) -> bytes:
331 | return await write(
332 | BanchoPackets.CHO_USER_LOGOUT,
333 | (id, Types.int32),
334 | (0, Types.uint8),
335 | )
336 |
337 |
338 | async def FriendsList(*ids: list[int]) -> bytes:
339 | return await write(BanchoPackets.CHO_FRIENDS_LIST, (ids, Types.int32_list))
340 |
341 |
342 | def get_match_struct(m: "Match", send_pass: bool = False) -> bytes:
343 | struct = [
344 | (m.match_id, Types.int16),
345 | (m.in_progress, Types.int8),
346 | (0, Types.byte),
347 | (m.mods, Types.uint32),
348 | (m.match_name, Types.string),
349 | ]
350 |
351 | if m.match_pass:
352 | if send_pass:
353 | struct.append((m.match_pass, Types.string))
354 | else:
355 | struct.append(("trollface", Types.string))
356 | else:
357 | struct.append(("", Types.string))
358 |
359 | struct.extend(
360 | (
361 | (m.map_title, Types.string),
362 | (m.map_id, Types.int32),
363 | (m.map_md5, Types.string),
364 | (m.slots, Types.multislots),
365 | (m.host, Types.int32),
366 | (m.mode.value, Types.byte),
367 | (m.scoring_type.value, Types.byte),
368 | (m.team_type.value, Types.byte),
369 | (m.freemods, Types.byte),
370 | )
371 | )
372 |
373 | if m.freemods:
374 | struct.append((m.slots, Types.multislotsmods))
375 |
376 | struct.append((m.seed, Types.int32))
377 |
378 | return struct
379 |
380 |
381 | async def Match(m: "Match") -> bytes:
382 | struct = get_match_struct(m)
383 | return await write(BanchoPackets.CHO_NEW_MATCH, *struct)
384 |
385 |
386 | async def MatchAllReady() -> bytes:
387 | return await write(BanchoPackets.CHO_MATCH_ALL_PLAYERS_LOADED)
388 |
389 |
390 | async def MatchComplete():
391 | return await write(BanchoPackets.CHO_MATCH_COMPLETE)
392 |
393 |
394 | async def MatchDispose(mid: int) -> bytes:
395 | return await write(BanchoPackets.CHO_DISPOSE_MATCH, (mid, Types.int32))
396 |
397 |
398 | async def MatchFail() -> bytes:
399 | return await write(BanchoPackets.CHO_MATCH_JOIN_FAIL)
400 |
401 |
402 | async def MatchInvite(m: "Match", p: "Player", reciever) -> bytes:
403 | return await write(
404 | BanchoPackets.CHO_MATCH_INVITE,
405 | ((p.username, f"#multi_{m.match_id}", reciever, p.id), Types.message),
406 | )
407 |
408 |
409 | async def MatchJoin(m: "Match") -> bytes:
410 | struct = get_match_struct(m, send_pass=True)
411 | return await write(BanchoPackets.CHO_MATCH_JOIN_SUCCESS, *struct)
412 |
413 |
414 | async def MatchPassChange(pwd: str) -> bytes:
415 | return await write(BanchoPackets.CHO_MATCH_CHANGE_PASSWORD, (pwd, Types.string))
416 |
417 |
418 | async def MatchPlayerFailed(pid: int) -> bytes:
419 | return await write(BanchoPackets.CHO_MATCH_PLAYER_FAILED, (pid, Types.int32))
420 |
421 |
422 | async def MatchScoreUpdate(s: "ScoreFrame", slot_id: int, raw_data: bytes) -> bytes:
423 | ret = bytearray(b"0\x00\x00")
424 |
425 | ret += len(raw_data).to_bytes(4, "little")
426 |
427 | ret += s.time.to_bytes(4, "little", signed="True")
428 | ret += struct.pack(" bytes:
450 | return await write(BanchoPackets.CHO_MATCH_PLAYER_SKIPPED, (pid, Types.int32))
451 |
452 |
453 | async def MatchSkip() -> bytes:
454 | return await write(BanchoPackets.CHO_MATCH_SKIP)
455 |
456 |
457 | async def MatchStart(m: "Match") -> bytes:
458 | struct = get_match_struct(m, send_pass=True)
459 | return await write(BanchoPackets.CHO_MATCH_START, *struct)
460 |
461 |
462 | async def MatchTransferHost() -> bytes:
463 | return await write(BanchoPackets.CHO_MATCH_TRANSFER_HOST)
464 |
465 |
466 | async def MatchUpdate(m: "Match") -> bytes:
467 | struct = get_match_struct(m, send_pass=True)
468 | return await write(BanchoPackets.CHO_UPDATE_MATCH, *struct)
469 |
470 |
471 | async def Pong() -> bytes:
472 | return await write(BanchoPackets.CHO_PONG)
473 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | oppai==4.1.0
2 | py3rijndael==0.3.3
3 | colored==1.4.2
4 | numpy==1.20.3
5 | osrparse==5.0.0
6 | LenHTTP==2.1.9
7 | aiomysql==0.0.21
8 | aiohttp==3.7.4.post0
9 | aiofiles==0.7.0
10 | bcrypt==3.2.0
11 |
--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------
1 | from objects.collections import Tokens, Channels, Matches
2 | from events import bancho, osu, avatar # dont remove
3 | from lenhttp import LenHTTP, Request
4 | from lib.database import Database
5 | from objects.bot import Louise
6 | from constants import commands # dont remove
7 | from objects import glob
8 | from utils import log
9 | import os
10 | import sys
11 |
12 | kwargs = {
13 | "logging": False,
14 | }
15 |
16 | glob.server = LenHTTP(("127.0.0.1", 8000), **kwargs)
17 |
18 |
19 | @glob.server.before_serving()
20 | async def startup():
21 | print(f"\033[94m{glob.title_card}\033[0m")
22 |
23 | glob.players = Tokens()
24 | glob.channels = Channels()
25 | glob.matches = Matches()
26 |
27 | for _path in (".data/avatars", ".data/replays", ".data/beatmaps"):
28 | if not os.path.exists(_path):
29 | log.warn(
30 | f"You're missing the folder {_path}! Don't worry we'll add it for you!"
31 | )
32 |
33 | os.makedirs(_path)
34 |
35 | log.info(f"Running Ragnarok on `{glob.domain}` (port: {glob.port})")
36 |
37 | log.info(".. Connecting to the database")
38 |
39 | glob.sql = Database()
40 | await glob.sql.connect(glob.config["mysql"])
41 |
42 | log.info("✓ Connected to the database!")
43 |
44 | log.info("... Connecting Louise to the server")
45 |
46 | if not await Louise.init():
47 | log.fail("✗ Couldn't find Louise in the database.")
48 | sys.exit()
49 |
50 | log.info("✓ Successfully connected Louise!")
51 |
52 | log.info("... Adding channels")
53 |
54 | async for channel in glob.sql.iterall(
55 | "SELECT name, description, public, staff, auto_join, read_only FROM channels"
56 | ):
57 | glob.channels.add_channel(channel)
58 |
59 | log.info("✓ Successfully added all avaliable channels")
60 |
61 | log.info("Finished up connecting to everything!")
62 |
63 |
64 | @avatar.avatar.after_request()
65 | @osu.osu.after_request()
66 | async def after_request(req: Request):
67 | if req.resp_code == 404:
68 | lprint = log.error
69 | else:
70 | lprint = log.info
71 |
72 | if req.resp_code != 500:
73 | lprint(f"[{req.type}] {req.path} | {req.elapsed}")
74 |
75 |
76 | @glob.server.add_middleware(500)
77 | async def fivehundred(req: Request, tb: str):
78 | log.fail(f"An error occured on `{req.path}` | {req.elapsed}\n{tb}")
79 |
80 | return b""
81 |
82 |
83 | glob.server.add_routers({bancho.bancho, avatar.avatar, osu.osu})
84 | glob.server.start()
85 |
--------------------------------------------------------------------------------
/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osumitsuha/Ragnarok/948214e20d4669ad1fe22416552665174f70363c/utils/__init__.py
--------------------------------------------------------------------------------
/utils/general.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 |
4 |
5 | def rag_round(value: float, decimals: int) -> float:
6 | tolerance = 10 ** decimals
7 |
8 | return int(value * tolerance + 0.5) / tolerance
9 |
10 |
11 | def random_string(len: int) -> str:
12 | return "".join(
13 | random.choice(string.ascii_lowercase + string.digits) for _ in range(len)
14 | )
15 |
--------------------------------------------------------------------------------
/utils/log.py:
--------------------------------------------------------------------------------
1 | import colored
2 |
3 |
4 | class Asora:
5 | DEBUG = colored.fg(107)
6 | INFO = colored.fg(111)
7 | CHAT = colored.fg(177)
8 | WARNING = colored.fg(130)
9 | ERROR = colored.fg(124)
10 |
11 | RESET = colored.attr("reset")
12 |
13 |
14 | def info(msg: str) -> str:
15 | print(f"[{Asora.INFO}info{Asora.RESET}]\t {msg}")
16 |
17 |
18 | def chat(msg: str) -> str:
19 | print(f"[{Asora.CHAT}chat{Asora.RESET}]\t {msg}")
20 |
21 |
22 | def debug(msg: str) -> str:
23 | print(f"[{Asora.DEBUG}debug{Asora.RESET}]\t {msg}")
24 |
25 |
26 | def warn(msg: str) -> str:
27 | print(f"[{Asora.WARNING}warn{Asora.RESET}]\t {msg}")
28 |
29 |
30 | def error(msg: str) -> str:
31 | print(f"[{Asora.ERROR}error{Asora.RESET}]\t {msg}")
32 |
33 |
34 | def fail(msg: str) -> str:
35 | print(f"[{Asora.ERROR}fail{Asora.RESET}]\t {msg}")
36 |
--------------------------------------------------------------------------------
/utils/replay.py:
--------------------------------------------------------------------------------
1 | from objects.player import Player
2 | from objects.score import Score
3 | from packets import writer
4 | from objects import glob
5 | from hashlib import md5
6 | import aiofiles
7 | import struct
8 |
9 |
10 | async def write_replay(replay=None, s=None, score_id=0, file_name="") -> None:
11 | if replay:
12 | raw = await replay.read()
13 | elif file_name:
14 | async with aiofiles.open(file_name, "rb") as file:
15 | raw = await file.read()
16 |
17 | if score_id and not s:
18 | play = await glob.sql.fetch(
19 | "SELECT s.id, s.user_id, s.hash_md5, s.score, s.pp, s.count_300, "
20 | "s.count_50, s.count_geki, s.count_katu, s.count_miss, s.count_100, "
21 | "s.max_combo, s.accuracy, s.perfect, s.rank, s.mods, s.passed, "
22 | "s.exited, s.play_time, s.mode, s.submitted, s.relax FROM scores s "
23 | "WHERE s.id = %s LIMIT 1",
24 | (score_id),
25 | )
26 |
27 | user_info = await glob.sql.fetch(
28 | "SELECT username, id, privileges, passhash " "FROM users WHERE id = %s",
29 | (play["user_id"]),
30 | )
31 |
32 | s = Score(p=Player(**user_info), **play)
33 |
34 | r_hash = md5(
35 | f"{s.count_100 + s.count_300}o{s.count_50}o{s.count_geki}o"
36 | f"{s.count_katu}t{s.count_miss}a{s.map.hash_md5}r{s.max_combo}e"
37 | f"{bool(s.perfect)}y{s.player.username}o{s.score}u{s.rank}{s.mods}True".encode()
38 | ).hexdigest()
39 |
40 | ret = bytearray()
41 |
42 | ret += struct.pack(" float:
9 | if mode == Mode.OSU:
10 | if glob.debug:
11 | log.debug("Calculating accuracy for standard")
12 |
13 | acc = (50 * count_50 + 100 * count_100 + 300 * count_300) / (
14 | 300 * (count_miss + count_50 + count_100 + count_300)
15 | )
16 |
17 | if mode == Mode.TAIKO:
18 | if glob.debug:
19 | log.debug("Calculating accuracy for taiko")
20 |
21 | acc = (0.5 * count_100 + count_300) / (count_miss + count_100 + count_300)
22 |
23 | if mode == Mode.CATCH:
24 | if glob.debug:
25 | log.debug("Calculating accuracy for catch the beat")
26 |
27 | acc = (count_50 + count_100 + count_300) / (
28 | count_katu + count_miss + count_50 + count_100 + count_300
29 | )
30 |
31 | if mode == Mode.MANIA:
32 | if glob.debug:
33 | log.debug("Calculating accuracy for mania")
34 |
35 | acc = (
36 | 50 * count_50
37 | + 100 * count_100
38 | + 200 * count_katu
39 | + 300 * (count_300 + count_geki)
40 | ) / (
41 | 300
42 | * (count_miss + count_50 + count_100 + count_katu + count_300 + count_geki)
43 | )
44 |
45 | return acc * 100
46 |
--------------------------------------------------------------------------------