1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Models\Traits;
7
8trait Scoreable
9{
10 protected $_enabledMods = null;
11
12 private float $_accuracy;
13 private int $_totalHits;
14
15 abstract public function getMode(): string;
16
17 public function getScoringType()
18 {
19 return 'score';
20 }
21
22 public function getEnabledModsAttribute($value)
23 {
24 return $this->_enabledMods ??= app('mods')->bitsetToIds($value);
25 }
26
27 public function totalHits(): int
28 {
29 return $this->_totalHits ??= match ($this->getMode()) {
30 'osu' =>
31 ($this->count50 + $this->count100 + $this->count300 + $this->countmiss) * 300,
32 'fruits' =>
33 $this->count50 + $this->count100 + $this->count300 + $this->countmiss + $this->countkatu,
34 'mania' => (
35 $this->getScoringType() === 'scorev2'
36 ? ($this->count50 + $this->count100 + $this->count300 + $this->countmiss + $this->countkatu + $this->countgeki) * 305
37 : ($this->count50 + $this->count100 + $this->count300 + $this->countmiss + $this->countkatu + $this->countgeki) * 300
38 ),
39 'taiko' =>
40 ($this->count100 + $this->count300 + $this->countmiss) * 300,
41 };
42 }
43
44 public function hits()
45 {
46 switch ($this->getMode()) {
47 case 'osu':
48 return $this->count50 * 50 + $this->count100 * 100 + $this->count300 * 300;
49 case 'fruits':
50 return $this->count50 + $this->count100 + $this->count300;
51 case 'mania':
52 if ($this->getScoringType() === 'scorev2') {
53 return $this->count50 * 50 + $this->count100 * 100 + $this->countkatu * 200 + $this->count300 * 300 + $this->countgeki * 305;
54 } else {
55 return $this->count50 * 50 + $this->count100 * 100 + $this->countkatu * 200 + ($this->count300 + $this->countgeki) * 300;
56 }
57 case 'taiko':
58 return $this->count100 * 150 + $this->count300 * 300;
59 }
60 }
61
62 public function accuracy(): float
63 {
64 if (!isset($this->_accuracy)) {
65 $totalHits = $this->totalHits();
66
67 // in a rare case when the score row has zero hits
68 // (found it occuring in multiplayer scores)
69 $this->_accuracy = $totalHits === 0 ? 1 : $this->hits() / $totalHits;
70 }
71
72 return $this->_accuracy;
73 }
74
75 public function recalculateRank(): void
76 {
77 if (!$this->pass) {
78 $this->rank = 'F';
79
80 return;
81 }
82
83 $totalHits = $this->totalHits();
84 $accuracy = $this->accuracy();
85 $countMiss = $this->countmiss;
86
87 switch ($this->getMode()) {
88 case 'osu':
89 $totalHitCount = $totalHits / 300;
90 $ratio300 = (float) ($totalHits === 0 ? 1 : $this->count300 / $totalHitCount);
91 $ratio50 = (float) ($totalHits === 0 ? 1 : $this->count50 / $totalHitCount);
92
93 $this->rank = match (true) {
94 $ratio300 === 1.0 =>
95 $this->shouldHaveHiddenRank() ? 'XH' : 'X',
96 $ratio300 > 0.9 && $ratio50 <= 0.01 && $countMiss === 0 =>
97 $this->shouldHaveHiddenRank() ? 'SH' : 'S',
98 ($ratio300 > 0.8 && $countMiss === 0) || $ratio300 > 0.9 =>
99 'A',
100 ($ratio300 > 0.7 && $countMiss === 0) || $ratio300 > 0.8 =>
101 'B',
102 $ratio300 > 0.6 =>
103 'C',
104 default =>
105 'D',
106 };
107
108 break;
109
110 case 'taiko':
111 $totalHitCount = $totalHits / 300;
112 $ratio300 = (float) ($totalHits === 0 ? 1 : $this->count300 / $totalHitCount);
113 $ratio50 = (float) ($totalHits === 0 ? 1 : $this->count50 / $totalHitCount);
114
115 $this->rank = match (true) {
116 $ratio300 === 1.0 =>
117 $this->shouldHaveHiddenRank() ? 'XH' : 'X',
118 $ratio300 > 0.9 && $ratio50 < 0.01 && $countMiss === 0 =>
119 $this->shouldHaveHiddenRank() ? 'SH' : 'S',
120 ($ratio300 > 0.8 && $countMiss === 0) || $ratio300 > 0.9 =>
121 'A',
122 ($ratio300 > 0.7 && $countMiss === 0) || $ratio300 > 0.8 =>
123 'B',
124 $ratio300 > 0.6 =>
125 'C',
126 default =>
127 'D',
128 };
129
130 break;
131
132 case 'fruits':
133 $this->rank = match (true) {
134 $accuracy === 1.0 =>
135 $this->shouldHaveHiddenRank() ? 'XH' : 'X',
136 $accuracy > 0.98 =>
137 $this->shouldHaveHiddenRank() ? 'SH' : 'S',
138 $accuracy > 0.94 =>
139 'A',
140 $accuracy > 0.9 =>
141 'B',
142 $accuracy > 0.85 =>
143 'C',
144 default =>
145 'D',
146 };
147
148 break;
149
150 case 'mania':
151 $this->rank = match (true) {
152 $accuracy === 1.0 =>
153 $this->shouldHaveHiddenRank() ? 'XH' : 'X',
154 $accuracy > 0.95 =>
155 $this->shouldHaveHiddenRank() ? 'SH' : 'S',
156 $accuracy > 0.9 =>
157 'A',
158 $accuracy > 0.8 =>
159 'B',
160 $accuracy > 0.7 =>
161 'C',
162 default =>
163 'D',
164 };
165
166 break;
167 }
168 }
169
170 private function shouldHaveHiddenRank(): bool
171 {
172 foreach ($this->enabled_mods as $mod) {
173 if ($mod === 'FI' || $mod === 'FL' || $mod === 'HD') {
174 return true;
175 }
176 }
177
178 return false;
179 }
180}