From 014f66c6ce6fad4da92ccf299395a356ec720733 Mon Sep 17 00:00:00 2001 From: freya Date: Fri, 9 Aug 2024 01:01:45 +0200 Subject: [PATCH] Loot solver v2 (#82) * updated test for new solver algorithm * wip new algorithm * arcadion savage test is passing * finalised moving old tests to new algorithm * add test for handling when loot is needed by fewer than existing people * if someone has multiple unique items, all of them should be added to the list since they're unique * fixed loot solver issue * linting * fixing some broken tests --- backend/api/tests/test_lodestone_scraper.py | 5 +- backend/api/tests/test_loot_solver.py | 578 ++++++++++++++++++-- backend/api/views/loot_solver.py | 130 ++++- backend/api/views/xivgear.py | 8 +- 4 files changed, 654 insertions(+), 67 deletions(-) diff --git a/backend/api/tests/test_lodestone_scraper.py b/backend/api/tests/test_lodestone_scraper.py index 46f758d..fc64d9d 100644 --- a/backend/api/tests/test_lodestone_scraper.py +++ b/backend/api/tests/test_lodestone_scraper.py @@ -12,6 +12,7 @@ class LodestoneScraper(SavageAimTestCase): """ Test that the Lodestone Scraper returns what is expected for its various methods """ + maxDiff = None def test_token_check(self): """ @@ -79,13 +80,13 @@ def test_get_current_gear(self): 'hands': 'Koga Tekko', 'legs': 'Augmented Ironworks Brais of Scouting', 'feet': 'Koga Kyahan', - 'earrings': 'Menphina\'s Earring', + 'earrings': 'Azeyma\'s Earrings', 'necklace': 'Aetherial Brass Gorget', 'bracelet': 'Dawn Wristguards', 'right_ring': 'Brand-new Ring', 'left_ring': 'Weathered Ring', }, - 'max_il': 430, + 'max_il': 560, 'min_il': 5, } data = SCRAPER.get_current_gear(ALT_CHAR_ID, 'NIN') diff --git a/backend/api/tests/test_loot_solver.py b/backend/api/tests/test_loot_solver.py index cc52737..5134fe6 100644 --- a/backend/api/tests/test_loot_solver.py +++ b/backend/api/tests/test_loot_solver.py @@ -616,8 +616,8 @@ def test_whole_view(self): content = response.json() first_floor_expected = [ - {'token': False, 'Earrings': self.tm5.id, 'Necklace': self.tm7.id, 'Bracelet': self.tm6.id, 'Ring': self.tm5.id}, - {'token': True, 'Earrings': self.tm6.id, 'Necklace': self.tm1.id, 'Bracelet': self.tm4.id, 'Ring': self.tm7.id}, + {'token': False, 'Earrings': self.tm5.id, 'Necklace': self.tm1.id, 'Bracelet': self.tm6.id, 'Ring': self.tm7.id}, + {'token': True, 'Earrings': self.tm6.id, 'Necklace': self.tm2.id, 'Bracelet': self.tm4.id, 'Ring': self.tm5.id}, ] first_floor_received = content['first_floor'] self.assertEqual(len(first_floor_expected), len(first_floor_received), first_floor_received) @@ -625,12 +625,12 @@ def test_whole_view(self): self.assertDictEqual(first_floor_expected[i], first_floor_received[i], f'{i+1}/{len(first_floor_received)}') second_floor_expected = [ - {'token': False, 'Head': self.tm1.id, 'Hands': self.tm1.id, 'Feet': self.tm5.id, 'Tome Accessory Augment': self.tm8.id}, - {'token': True, 'Head': self.tm7.id, 'Hands': None, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm2.id}, - {'token': False, 'Head': self.tm5.id, 'Hands': None, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm1.id}, + {'token': False, 'Head': self.tm5.id, 'Hands': self.tm1.id, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm8.id}, + {'token': True, 'Head': self.tm1.id, 'Hands': None, 'Feet': self.tm5.id, 'Tome Accessory Augment': self.tm2.id}, + {'token': False, 'Head': self.tm7.id, 'Hands': None, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm1.id}, {'token': False, 'Head': self.tm8.id, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm4.id}, - {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm7.id}, - {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm3.id}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm3.id}, + {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm2.id}, ] second_floor_received = content['second_floor'] self.assertEqual(len(second_floor_expected), len(second_floor_received), second_floor_received) @@ -642,9 +642,9 @@ def test_whole_view(self): {'token': False, 'Body': self.tm5.id, 'Legs': self.tm1.id, 'Tome Armour Augment': self.tm8.id}, {'token': False, 'Body': self.tm3.id, 'Legs': self.tm2.id, 'Tome Armour Augment': self.tm6.id}, {'token': True, 'Body': self.tm4.id, 'Legs': None, 'Tome Armour Augment': self.tm7.id}, - {'token': False, 'Body': self.tm8.id, 'Legs': None, 'Tome Armour Augment': self.tm5.id}, - {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm1.id}, - {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm3.id}, + {'token': False, 'Body': self.tm8.id, 'Legs': None, 'Tome Armour Augment': self.tm1.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm5.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm6.id}, {'token': True, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm2.id}, ] third_floor_received = content['third_floor'] @@ -771,8 +771,8 @@ def test_whole_view_split_loot(self): content = response.json() first_floor_expected = [ - {'token': False, 'Earrings': self.tm5.id, 'Necklace': self.tm7.id, 'Bracelet': self.tm6.id, 'Ring': self.tm5.id}, - {'token': True, 'Earrings': self.tm6.id, 'Necklace': self.tm1.id, 'Bracelet': self.tm4.id, 'Ring': self.tm7.id}, + {'token': False, 'Earrings': self.tm5.id, 'Necklace': self.tm1.id, 'Bracelet': self.tm6.id, 'Ring': self.tm7.id}, + {'token': True, 'Earrings': self.tm6.id, 'Necklace': self.tm2.id, 'Bracelet': self.tm4.id, 'Ring': self.tm5.id}, ] first_floor_received = content['first_floor'] self.assertEqual(len(first_floor_expected), len(first_floor_received), first_floor_received) @@ -780,13 +780,13 @@ def test_whole_view_split_loot(self): self.assertDictEqual(first_floor_expected[i], first_floor_received[i], f'{i+1}/{len(first_floor_received)}') second_floor_expected = [ - {'token': False, 'Head': self.tm1.id, 'Hands': self.tm1.id, 'Feet': self.tm5.id, 'Tome Accessory Augment': self.tm8.id}, - {'token': False, 'Head': self.tm7.id, 'Hands': None, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm2.id}, - {'token': True, 'Head': self.tm5.id, 'Hands': None, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm1.id}, + {'token': False, 'Head': self.tm5.id, 'Hands': self.tm1.id, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm8.id}, + {'token': False, 'Head': self.tm1.id, 'Hands': None, 'Feet': self.tm5.id, 'Tome Accessory Augment': self.tm2.id}, + {'token': True, 'Head': self.tm7.id, 'Hands': None, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm1.id}, {'token': False, 'Head': self.tm8.id, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm4.id}, - {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm7.id}, {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm3.id}, - {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm2.id}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm2.id}, + {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm5.id}, ] second_floor_received = content['second_floor'] self.assertEqual(len(second_floor_expected), len(second_floor_received), second_floor_received) @@ -798,9 +798,9 @@ def test_whole_view_split_loot(self): {'token': False, 'Body': self.tm5.id, 'Legs': self.tm1.id, 'Tome Armour Augment': self.tm8.id}, {'token': False, 'Body': self.tm3.id, 'Legs': self.tm2.id, 'Tome Armour Augment': self.tm6.id}, {'token': True, 'Body': self.tm4.id, 'Legs': None, 'Tome Armour Augment': self.tm7.id}, - {'token': False, 'Body': self.tm8.id, 'Legs': None, 'Tome Armour Augment': self.tm5.id}, - {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm1.id}, - {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm3.id}, + {'token': False, 'Body': self.tm8.id, 'Legs': None, 'Tome Armour Augment': self.tm1.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm5.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm6.id}, {'token': True, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm2.id}, ] third_floor_received = content['third_floor'] @@ -841,9 +841,9 @@ def test_solver_sort_overrides(self): content = response.json() first_floor_expected = [ - {'token': False, 'Earrings': self.tm6.id, 'Necklace': self.tm8.id, 'Bracelet': self.tm5.id, 'Ring': self.tm7.id}, - {'token': False, 'Earrings': self.tm5.id, 'Necklace': self.tm3.id, 'Bracelet': self.tm4.id, 'Ring': self.tm6.id}, - {'token': True, 'Earrings': None, 'Necklace': self.tm2.id, 'Bracelet': self.tm3.id, 'Ring': self.tm8.id}, + {'token': False, 'Earrings': self.tm6.id, 'Necklace': self.tm3.id, 'Bracelet': self.tm5.id, 'Ring': self.tm8.id}, + {'token': False, 'Earrings': self.tm5.id, 'Necklace': self.tm7.id, 'Bracelet': self.tm4.id, 'Ring': self.tm6.id}, + {'token': True, 'Earrings': None, 'Necklace': self.tm2.id, 'Bracelet': self.tm3.id, 'Ring': self.tm5.id}, ] first_floor_received = content['first_floor'] self.assertEqual(len(first_floor_expected), len(first_floor_received), first_floor_received) @@ -851,15 +851,16 @@ def test_solver_sort_overrides(self): self.assertDictEqual(first_floor_expected[i], first_floor_received[i], f'{i+1}/{len(first_floor_received)}') second_floor_expected = [ - {'token': False, 'Head': self.tm8.id, 'Hands': self.tm2.id, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm7.id}, - {'token': False, 'Head': self.tm1.id, 'Hands': self.tm7.id, 'Feet': self.tm5.id, 'Tome Accessory Augment': self.tm8.id}, - {'token': False, 'Head': self.tm2.id, 'Hands': self.tm1.id, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm4.id}, - {'token': True, 'Head': self.tm3.id, 'Hands': None, 'Feet': self.tm8.id, 'Tome Accessory Augment': self.tm7.id}, - {'token': False, 'Head': self.tm5.id, 'Hands': None, 'Feet': self.tm4.id, 'Tome Accessory Augment': self.tm2.id}, - {'token': False, 'Head': self.tm7.id, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm1.id}, - {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm6.id}, + {'token': False, 'Head': self.tm2.id, 'Hands': self.tm7.id, 'Feet': self.tm8.id, 'Tome Accessory Augment': self.tm1.id}, + {'token': False, 'Head': self.tm3.id, 'Hands': self.tm2.id, 'Feet': self.tm5.id, 'Tome Accessory Augment': self.tm8.id}, + {'token': False, 'Head': self.tm7.id, 'Hands': self.tm1.id, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm4.id}, + {'token': True, 'Head': self.tm8.id, 'Hands': None, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm2.id}, + {'token': False, 'Head': self.tm5.id, 'Hands': None, 'Feet': self.tm4.id, 'Tome Accessory Augment': self.tm7.id}, + {'token': False, 'Head': self.tm1.id, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm6.id}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm2.id}, {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm3.id}, ] + second_floor_received = content['second_floor'] self.assertEqual(len(second_floor_expected), len(second_floor_received), second_floor_received) for i in range(len(second_floor_expected)): @@ -870,9 +871,9 @@ def test_solver_sort_overrides(self): {'token': False, 'Body': self.tm8.id, 'Legs': self.tm7.id, 'Tome Armour Augment': self.tm3.id}, {'token': False, 'Body': self.tm5.id, 'Legs': self.tm1.id, 'Tome Armour Augment': self.tm6.id}, {'token': True, 'Body': self.tm4.id, 'Legs': None, 'Tome Armour Augment': self.tm2.id}, - {'token': False, 'Body': self.tm3.id, 'Legs': None, 'Tome Armour Augment': self.tm8.id}, - {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm7.id}, - {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm5.id}, + {'token': False, 'Body': self.tm3.id, 'Legs': None, 'Tome Armour Augment': self.tm7.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm8.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm6.id}, {'token': True, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm1.id}, ] third_floor_received = content['third_floor'] @@ -1026,14 +1027,513 @@ def test_handling_non_drop_gear(self): # Also run the second floor function and check that everything is returned in the order we expect second_floor_expected = [ - {'token': False, 'Head': self.tm1.id, 'Hands': self.tm1.id, 'Feet': self.tm5.id, 'Tome Accessory Augment': self.tm8.id}, - {'token': True, 'Head': self.tm7.id, 'Hands': None, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm2.id}, - {'token': False, 'Head': self.tm5.id, 'Hands': None, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm1.id}, - {'token': False, 'Head': self.tm8.id, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm4.id}, - {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm7.id}, - {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm3.id}, + {'token': False, 'Head': self.tm8.id, 'Hands': self.tm1.id, 'Feet': self.tm5.id, 'Tome Accessory Augment': self.tm2.id}, + {'token': True, 'Head': self.tm1.id, 'Hands': None, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm7.id}, + {'token': False, 'Head': self.tm5.id, 'Hands': None, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm8.id}, + {'token': False, 'Head': self.tm7.id, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm1.id}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm4.id}, + {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm2.id}, ] second_floor_received = LootSolver._get_second_floor_data(LootSolver._get_requirements_map(self.team), Loot.objects.all(), id_order, received) self.assertEqual(len(second_floor_expected), len(second_floor_received), second_floor_received) for i in range(len(second_floor_expected)): self.assertDictEqual(second_floor_expected[i], second_floor_received[i], f'{i+1}/{len(second_floor_received)}') + + +class LootSolverV2TestSuite(SavageAimTestCase): + """ + Test cases specifically for the more clever version of the algorithm. + Based around the current issue with M1S for us + """ + + def setUp(self): + """ + Seed the DB + """ + self.maxDiff = None + call_command('seed', stdout=StringIO()) + + self.tier = Tier.objects.get(max_item_level=735) + self.team = Team.objects.create( + invite_code=Team.generate_invite_code(), + name='The Testers', + tier=self.tier, + solver_sort_overrides={'VPR': 1, 'PCT': 7}, + ) + self.raid_weapon = Gear.objects.get(item_level=735, name='Dark Horse Champion') + self.raid_gear = Gear.objects.get(item_level=730, name='Dark Horse Champion') + self.tome_gear = Gear.objects.get(name='Augmented Quetzalli') + self.base_tome_gear = Gear.objects.get(name='Quetzalli') + crafted_gear = Gear.objects.get(name='Archeo Kingdom') + + # Make an ease of use map for current stuff to avoid redefining it over and over + current_map = { + 'current_mainhand': crafted_gear, + 'current_offhand': crafted_gear, + 'current_head': crafted_gear, + 'current_body': crafted_gear, + 'current_hands': crafted_gear, + 'current_legs': crafted_gear, + 'current_feet': crafted_gear, + 'current_earrings': crafted_gear, + 'current_necklace': crafted_gear, + 'current_bracelet': crafted_gear, + 'current_right_ring': crafted_gear, + 'current_left_ring': crafted_gear, + } + + # Make 8 Characters that represent the team members + self.c1 = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=self._get_user(), + name='C1', + verified=True, + world='Lich', + ) + self.c2 = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=self._get_user(), + name='C2', + verified=True, + world='Lich', + ) + self.c3 = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=self._get_user(), + name='C3', + verified=True, + world='Lich', + ) + self.c4 = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=self._get_user(), + name='C4', + verified=True, + world='Lich', + ) + self.c5 = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=self._get_user(), + name='C5', + verified=True, + world='Lich', + ) + self.c6 = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=self._get_user(), + name='C6', + verified=True, + world='Lich', + ) + self.c7 = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=self._get_user(), + name='C7', + verified=True, + world='Lich', + ) + self.c8 = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=self._get_user(), + name='C8', + verified=True, + world='Lich', + ) + + # Next make 8 BIS Lists, one for each, and link em to the team + self.b1 = BISList.objects.create( + owner=self.c1, + job_id='DRK', + bis_mainhand=self.raid_weapon, + bis_offhand=self.raid_weapon, + bis_head=self.raid_gear, + bis_body=self.tome_gear, + bis_hands=self.raid_gear, + bis_legs=self.raid_gear, + bis_feet=self.tome_gear, + bis_earrings=self.tome_gear, + bis_necklace=self.raid_gear, + bis_bracelet=self.tome_gear, + bis_right_ring=self.tome_gear, + bis_left_ring=self.base_tome_gear, + **current_map, + ) + self.tm1 = self.team.members.create(character=self.c1, bis_list=self.b1, permissions=0) + + self.b2 = BISList.objects.create( + owner=self.c2, + job_id='GNB', + bis_mainhand=self.raid_weapon, + bis_offhand=self.raid_weapon, + bis_head=self.raid_gear, + bis_body=self.tome_gear, + bis_hands=self.tome_gear, + bis_legs=self.raid_gear, + bis_feet=self.tome_gear, + bis_earrings=self.raid_gear, + bis_necklace=self.raid_gear, + bis_bracelet=self.tome_gear, + bis_right_ring=self.tome_gear, + bis_left_ring=self.raid_gear, + **current_map, + ) + self.tm2 = self.team.members.create(character=self.c2, bis_list=self.b2, permissions=0) + + self.b3 = BISList.objects.create( + owner=self.c3, + job_id='AST', + bis_mainhand=self.raid_weapon, + bis_offhand=self.raid_weapon, + bis_head=self.tome_gear, + bis_body=self.tome_gear, + bis_hands=crafted_gear, + bis_legs=self.tome_gear, + bis_feet=self.raid_gear, + bis_earrings=self.raid_gear, + bis_necklace=self.tome_gear, + bis_bracelet=self.raid_gear, + bis_right_ring=self.raid_gear, + bis_left_ring=self.tome_gear, + **current_map, + ) + self.tm3 = self.team.members.create(character=self.c3, bis_list=self.b3, lead=True) + + self.b4 = BISList.objects.create( + owner=self.c4, + job_id='SGE', + bis_mainhand=self.raid_weapon, + bis_offhand=self.raid_weapon, + bis_head=self.tome_gear, + bis_body=self.raid_gear, + bis_hands=crafted_gear, + bis_legs=self.tome_gear, + bis_feet=self.raid_gear, + bis_earrings=self.raid_gear, + bis_necklace=self.tome_gear, + bis_bracelet=self.raid_gear, + bis_right_ring=self.raid_gear, + bis_left_ring=self.tome_gear, + **current_map, + ) + self.tm4 = self.team.members.create(character=self.c4, bis_list=self.b4, permissions=0) + + self.b5 = BISList.objects.create( + owner=self.c5, + job_id='MNK', + bis_mainhand=self.raid_weapon, + bis_offhand=self.raid_weapon, + bis_head=self.tome_gear, + bis_body=self.tome_gear, + bis_hands=self.tome_gear, + bis_legs=self.raid_gear, + bis_feet=self.raid_gear, + bis_earrings=self.tome_gear, + bis_necklace=self.tome_gear, + bis_bracelet=self.raid_gear, + bis_right_ring=self.tome_gear, + bis_left_ring=self.raid_gear, + **current_map, + ) + self.tm5 = self.team.members.create(character=self.c5, bis_list=self.b5, permissions=0) + + self.b6 = BISList.objects.create( + owner=self.c6, + job_id='VPR', + bis_mainhand=self.raid_weapon, + bis_offhand=self.raid_weapon, + bis_head=self.raid_gear, + bis_body=self.raid_gear, + bis_hands=self.tome_gear, + bis_legs=self.tome_gear, + bis_feet=self.raid_gear, + bis_earrings=self.tome_gear, + bis_necklace=self.raid_gear, + bis_bracelet=self.tome_gear, + bis_right_ring=self.tome_gear, + bis_left_ring=self.raid_gear, + **current_map, + ) + self.tm6 = self.team.members.create(character=self.c6, bis_list=self.b6, permissions=0) + + self.b7 = BISList.objects.create( + owner=self.c7, + job_id='BRD', + bis_mainhand=self.raid_weapon, + bis_offhand=self.raid_weapon, + bis_head=self.raid_gear, + bis_body=self.tome_gear, + bis_hands=self.tome_gear, + bis_legs=self.raid_gear, + bis_feet=self.raid_gear, + bis_earrings=self.tome_gear, + bis_necklace=self.raid_gear, + bis_bracelet=self.tome_gear, + bis_right_ring=self.tome_gear, + bis_left_ring=self.raid_gear, + **current_map, + ) + self.tm7 = self.team.members.create(character=self.c7, bis_list=self.b7, permissions=0) + + self.b8 = BISList.objects.create( + owner=self.c8, + job_id='PCT', + bis_mainhand=self.raid_weapon, + bis_offhand=self.raid_weapon, + bis_head=self.tome_gear, + bis_body=self.raid_gear, + bis_hands=self.tome_gear, + bis_legs=self.tome_gear, + bis_feet=self.raid_gear, + bis_earrings=self.raid_gear, + bis_necklace=self.tome_gear, + bis_bracelet=self.raid_gear, + bis_right_ring=self.tome_gear, + bis_left_ring=self.raid_gear, + **current_map, + ) + self.tm8 = self.team.members.create(character=self.c8, bis_list=self.b8, permissions=0) + + def tearDown(self): + """ + Clean up the DB after each test + """ + Notification.objects.all().delete() + Loot.objects.all().delete() + TeamMember.objects.all().delete() + Team.objects.all().delete() + BISList.objects.all().delete() + Character.objects.all().delete() + + def test_requirements_map_generation(self): + """ + Build up a full test team, and run the requirements map function separately to ensure it builds the map correctly. + """ + # Generate the expected map + expected = { + 'earrings': [self.tm2.id, self.tm3.id, self.tm4.id, self.tm8.id], + 'necklace': [self.tm1.id, self.tm2.id, self.tm6.id, self.tm7.id], + 'bracelet': [self.tm3.id, self.tm4.id, self.tm5.id, self.tm8.id], + 'ring': [self.tm2.id, self.tm3.id, self.tm4.id, self.tm5.id, self.tm6.id, self.tm7.id, self.tm8.id], + 'head': [self.tm1.id, self.tm2.id, self.tm6.id, self.tm7.id], + 'hands': [self.tm1.id], + 'feet': [self.tm3.id, self.tm4.id, self.tm5.id, self.tm6.id, self.tm7.id, self.tm8.id], + 'tome-accessory-augment': [ + *([self.tm1.id] * 3), + *([self.tm2.id] * 2), + *([self.tm3.id] * 2), + *([self.tm4.id] * 2), + *([self.tm5.id] * 3), + *([self.tm6.id] * 3), + *([self.tm7.id] * 3), + *([self.tm8.id] * 2), + ], + 'body': [self.tm4.id, self.tm6.id, self.tm8.id], + 'legs': [self.tm1.id, self.tm2.id, self.tm5.id, self.tm7.id], + 'tome-armour-augment': [ + *([self.tm1.id] * 2), + *([self.tm2.id] * 3), + *([self.tm3.id] * 3), + *([self.tm4.id] * 2), + *([self.tm5.id] * 3), + *([self.tm6.id] * 2), + *([self.tm7.id] * 2), + *([self.tm8.id] * 3), + ], + 'mainhand': [self.tm1.id, self.tm2.id, self.tm3.id, self.tm4.id, self.tm5.id, self.tm6.id, self.tm7.id, self.tm8.id], + } + + received = dict(LootSolver._get_requirements_map(self.team)) + self.assertDictEqual(expected, received) + + def test_prio_bracket_generation(self): + """ + Create some test maps as subsets of the expected data from the last test, and get the expected priority brackets generated + """ + first_floor_requirements = { + 'earrings': [self.tm2.id, self.tm3.id, self.tm4.id, self.tm8.id], + 'necklace': [self.tm1.id, self.tm2.id, self.tm6.id, self.tm7.id], + 'bracelet': [self.tm3.id, self.tm4.id, self.tm5.id, self.tm8.id], + 'ring': [self.tm2.id, self.tm3.id, self.tm4.id, self.tm5.id, self.tm6.id, self.tm7.id, self.tm8.id], + } + second_floor_requirements = { + 'head': [self.tm1.id, self.tm2.id, self.tm6.id, self.tm7.id], + 'hands': [self.tm1.id], + 'feet': [self.tm3.id, self.tm4.id, self.tm5.id, self.tm6.id, self.tm7.id, self.tm8.id], + 'tome-accessory-augment': [ + *([self.tm1.id] * 3), + *([self.tm2.id] * 2), + *([self.tm3.id] * 2), + *([self.tm4.id] * 2), + *([self.tm5.id] * 3), + *([self.tm6.id] * 3), + *([self.tm7.id] * 3), + *([self.tm8.id] * 2), + ], + } + third_floor_requirements = { + 'body': [self.tm4.id, self.tm6.id, self.tm8.id], + 'legs': [self.tm1.id, self.tm2.id, self.tm5.id, self.tm7.id], + 'tome-armour-augment': [ + *([self.tm1.id] * 2), + *([self.tm2.id] * 3), + *([self.tm3.id] * 3), + *([self.tm4.id] * 2), + *([self.tm5.id] * 3), + *([self.tm6.id] * 2), + *([self.tm7.id] * 2), + *([self.tm8.id] * 3), + ], + } + ordering = [self.tm6.id, self.tm5.id, self.tm8.id, self.tm7.id, self.tm1.id, self.tm2.id, self.tm3.id, self.tm4.id] + + first_floor_expected = { + 3: [self.tm8.id, self.tm2.id, self.tm3.id, self.tm4.id], + 2: [self.tm6.id, self.tm5.id, self.tm7.id], + 1: [self.tm1.id], + } + second_floor_expected = { + 5: [self.tm6.id, self.tm7.id, self.tm1.id], + 4: [self.tm5.id], + 3: [self.tm8.id, self.tm2.id, self.tm3.id, self.tm4.id], + } + third_floor_expected = { + 4: [self.tm5.id, self.tm8.id, self.tm2.id], + 3: [self.tm6.id, self.tm7.id, self.tm1.id, self.tm3.id, self.tm4.id], + } + + self.assertDictEqual(LootSolver._generate_priority_brackets(first_floor_requirements, ordering), first_floor_expected) + self.assertDictEqual(LootSolver._generate_priority_brackets(second_floor_requirements, ordering), second_floor_expected) + self.assertDictEqual(LootSolver._generate_priority_brackets(third_floor_requirements, ordering), third_floor_expected) + + def test_whole_view(self): + """ + Test the M1S situation against the solver algorithm, ensure that we get back the expected responses + """ + + url = reverse('api:loot_solver', kwargs={'team_id': self.team.pk}) + user = self._get_user() + self.client.force_authenticate(user) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + first_floor_expected = [ + {'token': False, 'Earrings': self.tm8.id, 'Necklace': self.tm2.id, 'Bracelet': self.tm3.id, 'Ring': self.tm4.id}, + {'token': False, 'Earrings': self.tm2.id, 'Necklace': self.tm6.id, 'Bracelet': self.tm5.id, 'Ring': self.tm7.id}, + {'token': True, 'Earrings': self.tm4.id, 'Necklace': self.tm1.id, 'Bracelet': self.tm8.id, 'Ring': self.tm3.id}, + ] + first_floor_received = content['first_floor'] + self.assertEqual(len(first_floor_expected), len(first_floor_received), first_floor_received) + for i in range(len(first_floor_expected)): + self.assertDictEqual(first_floor_expected[i], first_floor_received[i], f'{i+1}/{len(first_floor_received)}') + + second_floor_expected = [ + {'token': False, 'Head': self.tm6.id, 'Hands': self.tm1.id, 'Feet': self.tm7.id, 'Tome Accessory Augment': self.tm5.id}, + {'token': False, 'Head': self.tm1.id, 'Hands': None, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm7.id}, + {'token': False, 'Head': self.tm2.id, 'Hands': None, 'Feet': self.tm8.id, 'Tome Accessory Augment': self.tm3.id}, + {'token': True, 'Head': self.tm7.id, 'Hands': None, 'Feet': self.tm4.id, 'Tome Accessory Augment': self.tm6.id}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': self.tm5.id, 'Tome Accessory Augment': self.tm1.id}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm2.id}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm8.id}, + {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm6.id}, + ] + second_floor_received = content['second_floor'] + self.assertEqual(len(second_floor_expected), len(second_floor_received), second_floor_received) + for i in range(len(second_floor_expected)): + self.assertDictEqual(second_floor_expected[i], second_floor_received[i], f'{i+1}/{len(second_floor_received)}') + + third_floor_expected = [ + {'token': False, 'Body': self.tm8.id, 'Legs': self.tm5.id, 'Tome Armour Augment': self.tm2.id}, + {'token': False, 'Body': self.tm6.id, 'Legs': self.tm7.id, 'Tome Armour Augment': self.tm1.id}, + {'token': False, 'Body': self.tm4.id, 'Legs': self.tm2.id, 'Tome Armour Augment': self.tm3.id}, + {'token': True, 'Body': None, 'Legs': self.tm1.id, 'Tome Armour Augment': self.tm8.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm5.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm6.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm7.id}, + {'token': True, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm3.id}, + ] + third_floor_received = content['third_floor'] + self.assertEqual(len(third_floor_expected), len(third_floor_received), third_floor_received) + for i in range(len(third_floor_expected)): + self.assertDictEqual(third_floor_expected[i], third_floor_received[i], f'{i+1}/{len(third_floor_received)}') + + self.assertEqual(content['fourth_floor'], {'weapons': 8, 'mounts': 8}) + + def test_for_single_person_requiring_loot(self): + """ + Make it so only a single person in the team requires loot, ensure the solver manages this scenario properly + """ + self.tm2.delete() + self.tm3.delete() + self.tm4.delete() + self.tm5.delete() + self.tm6.delete() + self.tm7.delete() + self.tm8.delete() + self.team.refresh_from_db() + + # Generate the expected requirements + expected = { + 'necklace': [self.tm1.id], + + 'head': [self.tm1.id], + 'hands': [self.tm1.id], + 'tome-accessory-augment': [ + *([self.tm1.id] * 3), + ], + + 'legs': [self.tm1.id], + 'tome-armour-augment': [ + *([self.tm1.id] * 2), + ], + + 'mainhand': [self.tm1.id], + } + received = dict(LootSolver._get_requirements_map(self.team)) + self.assertDictEqual(expected, received) + + # Now run the solver and ensure that the single user who needs stuff is funneled items as fast as possible + url = reverse('api:loot_solver', kwargs={'team_id': self.team.pk}) + user = self._get_user() + self.client.force_authenticate(user) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + first_floor_expected = [ + {'token': False, 'Earrings': None, 'Necklace': self.tm1.id, 'Bracelet': None, 'Ring': None}, + ] + first_floor_received = content['first_floor'] + self.assertEqual(len(first_floor_expected), len(first_floor_received), first_floor_received) + for i in range(len(first_floor_expected)): + self.assertDictEqual(first_floor_expected[i], first_floor_received[i], f'{i+1}/{len(first_floor_received)}') + + second_floor_expected = [ + {'token': False, 'Head': self.tm1.id, 'Hands': self.tm1.id, 'Feet': None, 'Tome Accessory Augment': self.tm1.id}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm1.id}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm1.id}, + ] + second_floor_received = content['second_floor'] + self.assertEqual(len(second_floor_expected), len(second_floor_received), second_floor_received) + for i in range(len(second_floor_expected)): + self.assertDictEqual(second_floor_expected[i], second_floor_received[i], f'{i+1}/{len(second_floor_received)}') + + third_floor_expected = [ + {'token': False, 'Body': None, 'Legs': self.tm1.id, 'Tome Armour Augment': self.tm1.id}, + {'token': False, 'Body': None, 'Legs': None, 'Tome Armour Augment': self.tm1.id}, + ] + third_floor_received = content['third_floor'] + self.assertEqual(len(third_floor_expected), len(third_floor_received), third_floor_received) + for i in range(len(third_floor_expected)): + self.assertDictEqual(third_floor_expected[i], third_floor_received[i], f'{i+1}/{len(third_floor_received)}') + + # Mounts will still be 8 since it's history based + self.assertEqual(content['fourth_floor'], {'weapons': 1, 'mounts': 1}) diff --git a/backend/api/views/loot_solver.py b/backend/api/views/loot_solver.py index 20ef10e..88d1fcb 100644 --- a/backend/api/views/loot_solver.py +++ b/backend/api/views/loot_solver.py @@ -262,6 +262,10 @@ def _get_floor_data( prio_brackets.pop(0, None) return clears, prio_brackets, floor_requirements + @staticmethod + def _get_output_slot_name(slot: str) -> str: + return slot.replace('-', ' ').title() + @staticmethod def _get_handout_data(slots: List[str], requirements: Requirements, prio_brackets: PrioBrackets, weeks_per_token: int, weeks: int) -> List[HandoutData]: """ @@ -273,39 +277,117 @@ def _get_handout_data(slots: List[str], requirements: Requirements, prio_bracket remove_slots = [remove_slots[-1]] + remove_slots[:-1] while len(prio_brackets) > 0: weeks += 1 - week_data = {'token': False} - # Run through the slots - for slot in slots: - # Check the prio brackets in descending order - assignee = None - for check_prio in sorted(prio_brackets, reverse=True): - # Run through the names and find the highest prio assignee - for check_id in prio_brackets.get(check_prio, []): - if check_id in requirements.get(slot, []): - assignee = check_id - requirements[slot].remove(assignee) - break - - if assignee is not None: + week_data = {} + + # Check what items we no longer need this week and add them to the handout info + for slot, needs in requirements.items(): + if len(needs) == 0: + week_data[LootSolver._get_output_slot_name(slot)] = None + + # Build up a map of who is needed to sort every required item for the week + required_slots_for_week = set(slot for slot in requirements if LootSolver._get_output_slot_name(slot) not in week_data) + needed_item_count = len(required_slots_for_week) + + # Loop through the people in priority order, populating a map of who needs what until all of the required items for the week are covered + # This ensures each item is given to the person with the highest priority of getting it + done = False + potential_loot_members: Dict[int, List[str]] = {} + for priority in sorted(prio_brackets, reverse=True): + for member_id in prio_brackets[priority]: + required = [slot for slot in requirements if member_id in requirements[slot]] + potential_loot_members[member_id] = required + + # Subtract from the set of things needed this week + required_slots_for_week -= set(required) + + # Check that we have enough potential members to cover each available item + if len(potential_loot_members) >= needed_item_count and len(required_slots_for_week) == 0: + done = True break - week_data[slot.replace('-', ' ').title()] = assignee + if done: + break + + # At this point, we have a mapping of potential member_ids to the items they still need this week. + # It has the minimum required amount of people such that every Need item can be handed out to someone. + # Now we determine who actually gets what + # There is a 3 step priority system to sorting out handouts; + # 1 - Anyone who has only one potential item + # 2 - Anyone who is the only person who needs a given item + # 3 - Go down the list from highest to lowest priority and just give them one of the needed items + # Whenever we give someone an item, we remove them from the potential list, remove their item from everyone elses' + # If anyone gets reduced to 1 item left, they get added to the queue + handout_queue = deque() + + # Handle the two special cases first + # 1 - Anyone who only has 1 item they can get + for member_id, member_items in potential_loot_members.items(): + if len(member_items) == 1: + handout_queue.append((member_id, member_items[0])) + + # 2 - Anyone who has a unique item in their list + for member_id, member_items in potential_loot_members.items(): + member_items_set = set(member_items) + other_set = set() + for other_member_id, other_member_items in potential_loot_members.items(): + if member_id == other_member_id: + continue + other_set |= set(other_member_items) + + uniques = member_items_set - other_set + for unique_item in uniques: + handout_queue.append((member_id, unique_item)) + + # Loop until we get all the requirements + while len(week_data) < len(requirements) and (len(potential_loot_members) > 0 or len(handout_queue) > 0): + # Check if we've already had someone in the handout queue, if not we get the first id and item + if len(handout_queue) > 0: + member_id, item = handout_queue.popleft() + else: + member_id = list(potential_loot_members)[0] + member_items = potential_loot_members.get(member_id, []) + if len(member_items) == 0: + # Remove the member and re-loop + potential_loot_members.pop(member_id, None) + continue + item = member_items[0] - if assignee is None: + # Attempt to give this item to the chosen member, if it's not already in the week's data + output_item_name = LootSolver._get_output_slot_name(item) + if output_item_name in week_data: continue + # At this point, the item is guaranteed to go to this person + week_data[output_item_name] = member_id + requirements[item].remove(member_id) + # Reduce the requirement number of the person and add them to the end of the list - new_prio = check_prio - 1 - prio_brackets[check_prio].remove(assignee) - if prio_brackets[check_prio] == []: + prio = None + for check_prio, names in prio_brackets.items(): + if member_id in names: + prio = check_prio + break + new_prio = prio - 1 + prio_brackets[prio].remove(member_id) + if prio_brackets[prio] == []: # If this empties the list, destroy it prio_brackets.pop(check_prio, None) if new_prio > 0: # If the assignee's new prio (number of items they need) isn't 0, add them to the lower prio + if new_prio not in prio_brackets: + prio_brackets[new_prio] = [] + prio_brackets[new_prio].append(member_id) + + # Now we need to remove the member_id from potentials, and also remove the item from anyone else + potential_loot_members.pop(member_id, None) + for other_member_id, other_member_items in potential_loot_members.items(): try: - prio_brackets[new_prio].append(assignee) - except KeyError: - # Defaultdict doesn't re-default if the list is removed - prio_brackets[new_prio] = [assignee] + other_member_items.remove(item) + except ValueError: + # If the item isn't in the list, that's fine + continue + if len(other_member_items) == 1: + # Put the person and their item into the queue + handout_queue.append((other_member_id, other_member_items[0])) # Add the week data to the handouts list handouts.append(week_data) @@ -334,6 +416,8 @@ def _get_handout_data(slots: List[str], requirements: Requirements, prio_bracket # 0 is also the max and we removed it? pass week_data['token'] = True + else: + week_data['token'] = False return handouts diff --git a/backend/api/views/xivgear.py b/backend/api/views/xivgear.py index 0859084..b4daea7 100644 --- a/backend/api/views/xivgear.py +++ b/backend/api/views/xivgear.py @@ -155,10 +155,12 @@ def get(self, request: Request, id: str) -> Response: # Use the returned map to calculate the min and max ils, and also replace IDs with names in sa_gear min_il = float('inf') max_il = float('-inf') + item_levels = {} for slot, xivapi_id in list(sa_gear.items()): details = gear_names[xivapi_id] sa_gear[slot] = details['name'] il = details['item_level'] + item_levels[details['name']] = il if il > max_il: max_il = il if il < min_il: @@ -176,11 +178,11 @@ def get(self, request: Request, id: str) -> Response: # Loop through each gear slot and fetch the id based off the name for slot, item_name in sa_gear.items(): if slot in self.ARMOUR_SLOTS: - imported_data[slot] = self._get_gear_id(gear_records.filter(has_armour=True), item_name) + imported_data[slot] = self._get_gear_id(gear_records.filter(has_armour=True, item_level=item_levels[item_name]), item_name) elif slot in self.ACCESSORY_SLOTS: - imported_data[slot] = self._get_gear_id(gear_records.filter(has_accessories=True), item_name) + imported_data[slot] = self._get_gear_id(gear_records.filter(has_accessories=True, item_level=item_levels[item_name]), item_name) else: - imported_data[slot] = self._get_gear_id(gear_records.filter(has_weapon=True), item_name) + imported_data[slot] = self._get_gear_id(gear_records.filter(has_weapon=True, item_level=item_levels[item_name]), item_name) # Check for offhand if job_id != 'PLD':