Lines 92.70% 127 / 137
Methods 57.89% 11 / 19
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 title 0.00% 0 / 1 0.00% 0 / 1 2
 rules 89.47% 17 / 19 0.00% 0 / 1 5.03
 levelUpIn 100.00% 9 / 9 100.00% 1 / 1 2
 attackIn 83.33% 5 / 6 0.00% 0 / 1 4.07
 below 100.00% 1 / 1 100.00% 1 / 1 1
 progress 100.00% 2 / 2 100.00% 1 / 1 1
 [Lorekeeper\Client\Bot\Script\BotScript] run 100.00% 2 / 2 100.00% 1 / 1 1
 [Lorekeeper\Client\Bot\Script\BotScript] join 100.00% 9 / 9 100.00% 1 / 1 3
 [Lorekeeper\Client\Bot\Script\BotScript] send 100.00% 1 / 1 100.00% 1 / 1 1
 [Lorekeeper\Client\Bot\Script\BotScript] jump 100.00% 2 / 2 100.00% 1 / 1 1
 [Lorekeeper\Client\Bot\Script\BotScript] kill 100.00% 18 / 18 100.00% 1 / 1 6
 [Lorekeeper\Client\Bot\Script\BotScript] resurrect 100.00% 6 / 6 100.00% 1 / 1 1
 [Lorekeeper\Client\Bot\Script\BotScript] targets 90.47% 19 / 21 0.00% 0 / 1 10.09
 [Lorekeeper\Client\Bot\Script\BotScript] readySkill 94.44% 17 / 18 0.00% 0 / 1 6.01
 [Lorekeeper\Client\Bot\Script\BotScript] nextAction 83.33% 5 / 6 0.00% 0 / 1 5.12
 [Lorekeeper\Client\Bot\Script\BotScript] firstAlive 100.00% 4 / 4 100.00% 1 / 1 4
 [Lorekeeper\Client\Bot\Script\BotScript] seen 87.50% 7 / 8 0.00% 0 / 1 5.05
 [Lorekeeper\Client\Bot\Script\BotScript] why 66.66% 2 / 3 0.00% 0 / 1 4.59
24final class LevelUpScript extends BotScript
25{
26    private int $from = 1;
27
28    /** @param null|Closure(): Timestamp $clock */
29    public function __construct(
30        private readonly PlayerLevel $target,
31        ?Closure $clock = null,
32    ) {
33        parent::__construct($clock);
34    }
35
36    #[Override]
37    public function title(): string
38    {
39        return "Level up to {$this->target->value}";
40    }
41
42    /** @return Generator<int, Instruction, null|Event|TimedOut, Done> */
43    #[Override]
44    protected function rules(): Generator
45    {
46        $this->from = $this->me->level()->value;
47
48        if ($this->below(10)) {
49            yield from $this->join(new MapName('oaklore'));
50            yield from $this->levelUpIn(new Cell('r3'), Pad::Left, upTo: 10);
51        }
52
53        if ($this->below(20)) {
54            yield from $this->join(new MapName('swordhavenundead'));
55            yield from $this->levelUpIn(new Cell('Gates'), Pad::Left, upTo: 20);
56        }
57
58        if ($this->below(75)) {
59            yield from $this->join(new MapName('icestormarena'));
60            yield from $this->levelUpIn(new Cell('r7'), Pad::Left, upTo: 25);
61            yield from $this->levelUpIn(new Cell('r10'), Pad::Left, upTo: 30);
62            yield from $this->levelUpIn(new Cell('r11'), Pad::Left, upTo: 35);
63            yield from $this->levelUpIn(new Cell('r14'), Pad::Left, upTo: 50);
64            yield from $this->levelUpIn(new Cell('r16'), Pad::Left, upTo: 61);
65            yield from $this->levelUpIn(new Cell('r17'), Pad::Left, upTo: 75);
66        }
67
68        if ($this->below(100)) {
69            yield from $this->join(new MapName('icestormunder'));
70            yield from $this->levelUpIn(new Cell('r2'), Pad::Top, upTo: 100);
71        }
72
73        return new Done("Reached level {$this->me->level()->value}");
74    }
75
76    /** @return Generator<int, Instruction, null|Event|TimedOut, void> */
77    private function levelUpIn(Cell $cell, Pad $pad, int $upTo): Generator
78    {
79        while ($this->below($upTo)) {
80            $next = $this->me->level()->value + 1;
81            yield from $this->kill(
82                $cell,
83                $pad,
84                $this->attackIn($cell),
85                fn(): bool => $this->me->level()->value >= $next,
86            );
87            yield $this->progress();
88        }
89    }
90
91    /** @throws RuntimeException */
92    private function attackIn(Cell $cell): AttackMonsterCommand
93    {
94        foreach ($this->me->map()->cells() as $mapCell) {
95            $spawn = $mapCell->spawns[0] ?? null;
96            if ($mapCell->cell->value !== $cell->value || $spawn === null) {
97                continue;
98            }
99
100            return new AttackMonsterCommand($this->me->position()->area, SkillSlot::AutoAttack, $spawn->id);
101        }
102
103        throw new RuntimeException("There are no monsters in {$cell->value}.");
104    }
105
106    private function below(int $level): bool
107    {
108        return $this->me->level()->value < min($level, $this->target->value);
109    }
110
111    private function progress(): Progress
112    {
113        $total = max(1, $this->target->value - $this->from);
114
115        return new Progress(max(0, min($total, $this->me->level()->value - $this->from)), $total);
116    }
117}

Inherited from Lorekeeper\Client\Bot\Script\BotScript

77    final public function run(Character $me): Generator
78    {
79        $this->me = $me;
80
81        return $this->rules();
82    }
89    protected function join(MapName $map): Generator
90    {
91        if ($this->me->position()->map->value === $map->value) {
92            return;
93        }
94
95        /** @var Event|TimedOut $joined */
96        $joined = yield $this->send(new JoinMapCommand($this->me->name(), $map, Option\none()))->awaits(
97            Duration::seconds(15),
98            MapJoins::class,
99            MapRefusals::class,
100        );
101
102        if (!MapJoins::contains($joined)) {
103            throw new RuntimeException("Could not join {$map->value}: " . self::why($joined));
104        }
105    }
107    protected function send(Command $command): Send
108    {
109        return new Send($command);
110    }
113    protected function jump(Cell $cell, Pad $pad): Generator
114    {
115        yield $this->send(new MoveToCellCommand($this->me->position()->area, $cell, $pad));
116        yield new Pause(Duration::seconds(1));
117    }
126    protected function kill(Cell $cell, Pad $pad, Attack $attack, Closure $until): Generator
127    {
128        yield from $this->jump($cell, $pad);
129        $targets = $this->targets($cell, $attack);
130
131        $dead = [];
132        $ready = [];
133        while (!$until()) {
134            if (!$this->me->alive()) {
135                yield from $this->resurrect($cell, $pad);
136                $dead = [];
137
138                continue;
139            }
140
141            $target = self::firstAlive($targets, $dead);
142            $now = ($this->clock)();
143            $skill = $target === null ? null : $this->readySkill($ready, $now);
144            if ($target !== null && $skill !== null) {
145                yield $this->send($attack->at($target)->with($skill->slot));
146                $ready[$skill->slot->name] = $now->plus($skill->cooldown);
147                $ready['next'] = $now->plus(Duration::seconds(1));
148            }
149
150            $answer = yield new Await(self::FIGHT_EVENTS, self::nextAction($ready, $now));
151            $dead = self::seen($answer, $dead);
152        }
153    }
156    private function resurrect(Cell $cell, Pad $pad): Generator
157    {
158        yield new Pause(Duration::seconds(10));
159        yield $this->send(new ResurrectCommand($this->me->position()->area, $this->me->socket()))->awaits(
160            Duration::seconds(15),
161            ResurrectedEvent::class,
162        );
163        yield from $this->jump($cell, $pad);
164    }
171    private function targets(Cell $cell, Attack $attack): array
172    {
173        $aimed = $attack->target();
174        if (!$aimed instanceof MonsterSpawnId) {
175            return [$aimed];
176        }
177
178        $spawns = [];
179        foreach ($this->me->map()->cells() as $mapCell) {
180            if ($mapCell->cell->value !== $cell->value) {
181                continue;
182            }
183
184            $spawns = $mapCell->spawns;
185        }
186
187        $species = null;
188        foreach ($spawns as $spawn) {
189            if ($spawn->id->value !== $aimed->value) {
190                continue;
191            }
192
193            $species = $spawn->monster->id->value;
194        }
195
196        if ($species === null) {
197            throw new RuntimeException("Monster {$aimed->value} does not stand in {$cell->value}.");
198        }
199
200        $same = [];
201        foreach ($spawns as $spawn) {
202            if ($spawn->monster->id->value !== $species || $spawn->id->value === $aimed->value) {
203                continue;
204            }
205
206            $same[] = $spawn->id;
207        }
208
209        return [$aimed, ...$same];
210    }
213    private function readySkill(array $ready, Timestamp $now): ?Skill
214    {
215        if (array_key_exists('next', $ready) && $ready['next']->after($now)) {
216            return null;
217        }
218
219        $usable = array_filter(
220            $this->me->skills(),
221            fn(Skill $skill): bool => (
222                $skill->target === SkillTarget::Enemy
223                && !(array_key_exists($skill->slot->name, $ready) && $ready[$skill->slot->name]->after($now))
224                && $skill->mana->value
225                <= ($this->me->mana()->value ?? 100)
226            ),
227        );
228
229        usort(
230            $usable,
231            static fn(Skill $a, Skill $b): int => (
232                (int) ($a->slot === SkillSlot::AutoAttack) <=> (int) ($b->slot === SkillSlot::AutoAttack)
233            ),
234        );
235
236        return $usable[0] ?? null;
237    }
240    private static function nextAction(array $ready, Timestamp $now): Duration
241    {
242        $soonest = Duration::seconds(2);
243        foreach ($ready as $at) {
244            $left = $at->since($now);
245            if ($left->isPositive() && $left->compare($soonest) < 0) {
246                $soonest = $left;
247            }
248        }
249
250        return $soonest->compare(Duration::milliseconds(200)) < 0 ? Duration::milliseconds(200) : $soonest;
251    }
257    private static function firstAlive(array $targets, array $dead): ?Targetable
258    {
259        foreach ($targets as $target) {
260            if (!$target instanceof MonsterSpawnId || !array_key_exists($target->value, $dead)) {
261                return $target;
262            }
263        }
264
265        return null;
266    }
273    private static function seen(null|Event|TimedOut $answer, array $dead): array
274    {
275        if ($answer instanceof MonstersDiedEvent) {
276            foreach ($answer->monsters as $monster) {
277                $dead[$monster->value] = true;
278            }
279        }
280
281        if ($answer instanceof MonsterRewardedEvent) {
282            $dead[$answer->monster->value] = true;
283        }
284
285        if ($answer instanceof MonsterRespawnedEvent) {
286            unset($dead[$answer->id->value]);
287        }
288
289        return $dead;
290    }
292    private static function why(mixed $answer): string
293    {
294        return match (true) {
295            $answer instanceof Event => $answer->summary(),
296            $answer instanceof TimedOut => 'no answer',
297            default => 'the server refused',
298        };
299    }