Lines 97.64% 207 / 212
Methods 92.85% 13 / 14
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 mentions 100.00% 1 / 1 100.00% 1 / 1 1
 from 100.00% 8 / 8 100.00% 1 / 1 4
 questType 100.00% 49 / 49 100.00% 1 / 1 1
 quest 100.00% 17 / 17 100.00% 1 / 1 2
 requirements 100.00% 21 / 21 100.00% 1 / 1 10
 rewards 100.00% 15 / 15 100.00% 1 / 1 6
 rewardType 100.00% 4 / 4 100.00% 1 / 1 4
 mentionsOf 100.00% 27 / 27 100.00% 1 / 1 8
 itemValues 100.00% 20 / 20 100.00% 1 / 1 7
 detail 100.00% 28 / 28 100.00% 1 / 1 28
 slot 100.00% 10 / 10 100.00% 1 / 1 10
 summary 0.00% 0 / 5 0.00% 0 / 1 2
 [Lorekeeper\Server\Event\NamedAfterClass] name 100.00% 6 / 6 100.00% 1 / 1 1
65final readonly class QuestLoadedEvent implements Event, Mentions
66{
67    use NamedAfterClass;
68
69    private const int NO_FACTION = 1;
70
71    /**
72     * @param list<QuestLoadedQuest> $quests
73     * @param list<Mention> $mentions
74     */
75    public function __construct(
76        public array $quests,
77        private array $mentions,
78    ) {}
79
80    /** @return list<Mention> */
81    #[Override]
82    public function mentions(): array
83    {
84        return $this->mentions;
85    }
86
87    /** @return Option\Option<static> */
88    #[Override]
89    public static function from(Message $message): Option\Option
90    {
91        if (!$message instanceof JsonMessage || ($message->data['cmd'] ?? null) !== 'getQuests') {
92            return Option\none();
93        }
94
95        try {
96            $quests = array_values(Coerce\dict(Coerce\array_key(), self::questType())->coerce(
97                $message->data['quests'] ?? null,
98            ));
99        } catch (CoercionException $exception) {
100            throw new MalformedEventException(static::class, 'quests are missing or invalid', $exception);
101        }
102
103        return Option\some(new static(array_map(self::quest(...), $quests), self::mentionsOf($quests)));
104    }
105
106    /** @return Coerce\TypeInterface<Quest> */
107    private static function questType(): Coerce\TypeInterface
108    {
109        $items = Coerce\optional(Coerce\dict(Coerce\array_key(), Coerce\shape([
110            'ItemID' => Coerce\positive_int(),
111            'sName' => Coerce\non_empty_string(),
112            'sType' => Coerce\string(),
113            'sES' => Coerce\string(),
114            'sDesc' => Coerce\optional(Coerce\string()),
115            'iCost' => Coerce\optional(Coerce\uint()),
116            'bCoins' => Coerce\optional(Coerce\bool()),
117            'sFile' => Coerce\optional(Coerce\nullable(Coerce\string())),
118            'sLink' => Coerce\optional(Coerce\string()),
119            'bUpg' => Coerce\optional(Coerce\bool()),
120        ], allowUnknownFields: true)));
121        $entry = Coerce\shape([
122            'ItemID' => Coerce\positive_int(),
123            'iQty' => Coerce\positive_int(),
124        ], allowUnknownFields: true);
125
126        return Coerce\shape([
127            'QuestID' => Coerce\positive_int(),
128            'sName' => Coerce\non_empty_string(),
129            'sDesc' => Coerce\string(),
130            'sEndText' => Coerce\optional(Coerce\string()),
131            'iLvl' => Coerce\uint(),
132            'iExp' => Coerce\uint(),
133            'iGold' => Coerce\uint(),
134            'iClass' => Coerce\uint(),
135            'sClass' => Coerce\optional(Coerce\string()),
136            'iReqCP' => Coerce\uint(),
137            'FactionID' => Coerce\positive_int(),
138            'sFaction' => Coerce\string(),
139            'iReqRep' => Coerce\uint(),
140            'iRep' => Coerce\uint(),
141            'bUpg' => Coerce\bool(),
142            'bQuest' => Coerce\optional(Coerce\union(Coerce\bool(), Coerce\int())),
143            'strPrevious' => Coerce\optional(Coerce\string()),
144            'turnin' => Coerce\optional(Coerce\vec($entry)),
145            'reward' => Coerce\optional(Coerce\vec(Coerce\shape([
146                'ItemID' => Coerce\positive_int(),
147                'iQty' => Coerce\positive_int(),
148                'iRate' => Coerce\num(),
149                'iType' => Coerce\int(),
150            ], allowUnknownFields: true))),
151            'reqd' => Coerce\optional(Coerce\vec($entry)),
152            'oItems' => $items,
153            'oReqd' => $items,
154            'oRewards' => Coerce\optional(Coerce\shape([
155                'itemsS' => $items,
156                'itemsR' => $items,
157            ], allowUnknownFields: true)),
158        ], allowUnknownFields: true);
159    }
160
161    /** @param Quest $quest */
162    private static function quest(array $quest): QuestLoadedQuest
163    {
164        $description = trim($quest['sDesc']);
165
166        return new QuestLoadedQuest(
167            new QuestId($quest['QuestID']),
168            new QuestName($quest['sName']),
169            $description === ''
170                ? Option\none()
171                : Option\some(new QuestDescription($quest['sDesc'], trim($quest['sEndText'] ?? ''))),
172            self::requirements($quest),
173            array_map(
174                static fn(array $turnIn): QuestObjective => new ItemObjective(
175                    new ItemId($turnIn['ItemID']),
176                    $turnIn['iQty'],
177                ),
178                $quest['turnin'] ?? [],
179            ),
180            self::rewards($quest),
181        );
182    }
183
184    /**
185     * @param Quest $quest
186     *
187     * @return list<QuestRequirement>
188     */
189    private static function requirements(array $quest): array
190    {
191        $requirements = [];
192
193        if ($quest['iLvl'] > 1) {
194            $requirements[] = new PlayerLevelRequirement(new PlayerLevel($quest['iLvl']));
195        }
196
197        if ($quest['iClass'] > 0) {
198            $requirements[] = new ClassRankRequirement(
199                new ClassId($quest['iClass']),
200                Rank::fromPoints(new ClassPoints($quest['iReqCP'])),
201            );
202        }
203
204        if ($quest['iReqRep'] > 0 && $quest['FactionID'] !== self::NO_FACTION) {
205            $requirements[] = new FactionRankRequirement(
206                new FactionId($quest['FactionID']),
207                Rank::fromPoints(new Reputation($quest['iReqRep'])),
208            );
209        }
210
211        if ($quest['bUpg']) {
212            $requirements[] = new MemberRequirement();
213        }
214
215        $previous = $quest['strPrevious'] ?? '';
216        if (($quest['bQuest'] ?? false) && ctype_digit($previous) && (int) $previous > 0) {
217            $requirements[] = new PreviousQuestRequirement(new QuestId((int) $previous));
218        }
219
220        foreach ($quest['reqd'] ?? [] as $item) {
221            $requirements[] = new ItemRequirement(new ItemId($item['ItemID']), $item['iQty']);
222        }
223
224        return $requirements;
225    }
226
227    /**
228     * @param Quest $quest
229     *
230     * @return list<QuestReward>
231     */
232    private static function rewards(array $quest): array
233    {
234        $rewards = [];
235
236        if ($quest['iGold'] > 0) {
237            $rewards[] = new GoldReward(new Gold($quest['iGold']));
238        }
239
240        if ($quest['iExp'] > 0) {
241            $rewards[] = new ExperienceReward(new Experience($quest['iExp']));
242        }
243
244        if ($quest['iRep'] > 0 && $quest['FactionID'] !== self::NO_FACTION) {
245            $rewards[] = new ReputationReward(new FactionId($quest['FactionID']), new Reputation($quest['iRep']));
246        }
247
248        foreach ($quest['reward'] ?? [] as $item) {
249            $rewards[] = new ItemReward(
250                new ItemId($item['ItemID']),
251                new DropRate((float) $item['iRate']),
252                $item['iQty'],
253                self::rewardType($item['iType']),
254            );
255        }
256
257        return $rewards;
258    }
259
260    /** @throws MalformedEventException */
261    private static function rewardType(int $type): ItemRewardType
262    {
263        return match ($type) {
264            0 => ItemRewardType::Guaranteed,
265            1 => ItemRewardType::Random,
266            default => throw new MalformedEventException(static::class, "unknown reward type '{$type}'"),
267        };
268    }
269
270    /**
271     * @param list<Quest> $quests
272     *
273     * @return list<Mention<ItemId|FactionId, object>>
274     */
275    private static function mentionsOf(array $quests): array
276    {
277        $mentions = [];
278        foreach ($quests as $quest) {
279            $rewards = $quest['oRewards'] ?? [];
280            $items = [
281                ...($quest['oItems'] ?? []),
282                ...($quest['oReqd'] ?? []),
283                ...($rewards['itemsS'] ?? []),
284                ...($rewards['itemsR'] ?? []),
285            ];
286            foreach ($items as $item) {
287                foreach (self::itemValues($item) as $value) {
288                    $mentions['item:' . $item['ItemID'] . ':' . $value::class] ??= new Mention(
289                        new ItemId($item['ItemID']),
290                        $value,
291                    );
292                }
293            }
294
295            if ($quest['FactionID'] !== self::NO_FACTION && $quest['sFaction'] !== '') {
296                $mentions["faction:{$quest['FactionID']}"] ??= new Mention(
297                    new FactionId($quest['FactionID']),
298                    new FactionName($quest['sFaction']),
299                );
300            }
301
302            $class = trim($quest['sClass'] ?? '');
303            if ($quest['iClass'] > 0 && $class !== '') {
304                $mentions["class:{$quest['iClass']}"] ??= new Mention(
305                    new ClassId($quest['iClass']),
306                    new ClassName($class),
307                );
308            }
309        }
310
311        return array_values($mentions);
312    }
313
314    /**
315     * @param Item $item
316     *
317     * @return list<object>
318     */
319    private static function itemValues(array $item): array
320    {
321        $description = trim($item['sDesc'] ?? '');
322        $cost = $item['iCost'] ?? 0;
323        $file = $item['sFile'] ?? '';
324        $link = $item['sLink'] ?? '';
325        $hasFile = str_ends_with($file, '.swf');
326
327        return [
328            new ItemName($item['sName']),
329            self::detail($item['sType']),
330            ...($description === '' ? [] : [new ItemDescription($description)]),
331            ...(
332                $cost === 0
333                    ? []
334                    : [new ItemPrice($item['bCoins'] ?? false ? new AdventureCoins($cost) : new Gold($cost))]
335            ),
336            ...($hasFile ? [new Gamefile($file)] : []),
337            ...($hasFile && $link !== '' ? [new Linkage($link)] : []),
338            ...self::slot($item['sES'])
339                ->map(static fn(EquipmentSlot $slot): array => [$slot])
340                ->unwrapOr([]),
341        ];
342    }
343
344    /** @throws MalformedEventException */
345    private static function detail(string $type): Type\Equipment|Type\Weapon|Type\House|Type\Misc
346    {
347        return match ($type) {
348            'Armor' => Type\Equipment::Armor,
349            'Cape' => Type\Equipment::Cape,
350            'Class' => Type\Equipment::CharacterClass,
351            'Misc' => Type\Equipment::Ground,
352            'Helm' => Type\Equipment::Helm,
353            'Necklace' => Type\Equipment::Necklace,
354            'Pet' => Type\Equipment::Pet,
355            'Axe' => Type\Weapon::Axe,
356            'Bow' => Type\Weapon::Bow,
357            'Dagger' => Type\Weapon::Dagger,
358            'Gauntlet' => Type\Weapon::Gauntlet,
359            'Gun' => Type\Weapon::Gun,
360            'HandGun' => Type\Weapon::HandGun,
361            'Mace' => Type\Weapon::Mace,
362            'Polearm' => Type\Weapon::Polearm,
363            'Rifle' => Type\Weapon::Rifle,
364            'Staff' => Type\Weapon::Staff,
365            'Sword' => Type\Weapon::Sword,
366            'Wand' => Type\Weapon::Wand,
367            'Whip' => Type\Weapon::Whip,
368            'Floor Item' => Type\House::Floor,
369            'House' => Type\House::House,
370            'Wall Item' => Type\House::Wall,
371            'ServerUse' => Type\Misc::Boost,
372            'Enhancement' => Type\Misc::Enhancement,
373            'Item', 'Note', 'Quest Item', 'Resource' => Type\Misc::Generic,
374            default => throw new MalformedEventException(static::class, "unknown item type '{$type}'"),
375        };
376    }
377
378    /**
379     * @throws MalformedEventException
380     *
381     * @return Option\Option<EquipmentSlot>
382     */
383    private static function slot(string $code): Option\Option
384    {
385        return match ($code) {
386            'None' => Option\none(),
387            'Weapon' => Option\some(EquipmentSlot::Weapon),
388            'ar', 'co' => Option\some(EquipmentSlot::Armor),
389            'he' => Option\some(EquipmentSlot::Helm),
390            'ba' => Option\some(EquipmentSlot::Cape),
391            'pe' => Option\some(EquipmentSlot::Pet),
392            'mi' => Option\some(EquipmentSlot::Ground),
393            'am' => Option\some(EquipmentSlot::Necklace),
394            default => throw new MalformedEventException(static::class, "unknown equipment slot '{$code}'"),
395        };
396    }
397
398    #[Override]
399    public function summary(): string
400    {
401        return (
402            count($this->quests)
403            . ' quests: '
404            . implode(', ', array_map(static fn(QuestLoadedQuest $quest): string => $quest->name->value, $this->quests))
405        );
406    }
407}

From Lorekeeper\Server\Event\NamedAfterClass

7trait NamedAfterClass
8{
9    public function name(): string
10    {
11        $class = static::class;
12
13        return (string) preg_replace(
14            '/Event$/',
15            replacement: '',
16            subject: substr($class, (int) strrpos($class, needle: '\\') + 1),
17        );
18    }
19}