Lines 93.93% 93 / 99
Methods 61.53% 8 / 13
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 rules n/a 0 / 0 n/a 0 / 0 0
 run 100.00% 2 / 2 100.00% 1 / 1 1
 join 100.00% 9 / 9 100.00% 1 / 1 3
 send 100.00% 1 / 1 100.00% 1 / 1 1
 jump 100.00% 2 / 2 100.00% 1 / 1 1
 kill 100.00% 18 / 18 100.00% 1 / 1 6
 resurrect 100.00% 6 / 6 100.00% 1 / 1 1
 targets 90.47% 19 / 21 0.00% 0 / 1 10.09
 readySkill 94.44% 17 / 18 0.00% 0 / 1 6.01
 nextAction 83.33% 5 / 6 0.00% 0 / 1 5.12
 firstAlive 100.00% 4 / 4 100.00% 1 / 1 4
 seen 87.50% 7 / 8 0.00% 0 / 1 5.05
 why 66.66% 2 / 3 0.00% 0 / 1 4.59
51abstract class BotScript implements Script
52{
53    private const array FIGHT_EVENTS = [
54        MonstersDiedEvent::class,
55        MonsterRewardedEvent::class,
56        MonsterRespawnedEvent::class,
57        PlayerDiedEvent::class,
58        LeveledUpEvent::class,
59    ];
60
61    protected Character $me;
62
63    /** @var Closure(): Timestamp */
64    private readonly Closure $clock;
65
66    /** @param null|Closure(): Timestamp $clock */
67    public function __construct(?Closure $clock = null)
68    {
69        $this->clock = $clock ?? Timestamp::monotonic(...);
70    }
71
72    /** @return Generator<int, Instruction, null|Event|TimedOut, object> */
73    abstract protected function rules(): Generator;
74
75    /** @return Generator<int, Instruction, null|Event|TimedOut, object> */
76    #[Override]
77    final public function run(Character $me): Generator
78    {
79        $this->me = $me;
80
81        return $this->rules();
82    }
83
84    /**
85     * @throws RuntimeException
86     *
87     * @return Generator<int, Instruction, null|Event|TimedOut, void>
88     */
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    }
106
107    protected function send(Command $command): Send
108    {
109        return new Send($command);
110    }
111
112    /** @return Generator<int, Instruction, null|Event|TimedOut, void> */
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    }
118
119    /**
120     * @param Closure(): bool $until
121     *
122     * @throws RuntimeException
123     *
124     * @return Generator<int, Instruction, null|Event|TimedOut, void>
125     */
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    }
154
155    /** @return Generator<int, Instruction, null|Event|TimedOut, void> */
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    }
165
166    /**
167     * @throws RuntimeException
168     *
169     * @return non-empty-list<Targetable>
170     */
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    }
211
212    /** @param array<string, Timestamp> $ready */
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    }
238
239    /** @param array<string, Timestamp> $ready */
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    }
252
253    /**
254     * @param non-empty-list<Targetable> $targets
255     * @param array<int, true> $dead
256     */
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    }
267
268    /**
269     * @param array<int, true> $dead
270     *
271     * @return array<int, true>
272     */
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    }
291
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    }
300}