diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..99d2df1 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +**/__pycache__ +api/tests +deployment +.coveragerc +.dockerignore diff --git a/backend/api/management/commands/seed_data/gear/7-dt/7.2.yml b/backend/api/management/commands/seed_data/gear/7-dt/7.2.yml new file mode 100644 index 0000000..108ca84 --- /dev/null +++ b/backend/api/management/commands/seed_data/gear/7-dt/7.2.yml @@ -0,0 +1,38 @@ +- has_accessories: True + has_armour: True + has_weapon: True + item_level: 740 + name: 'Ceremonial' +- has_accessories: True + has_armour: True + has_weapon: False + item_level: 740 + name: 'Cruiser' +- has_accessories: False + has_armour: False + has_weapon: True + item_level: 745 + name: 'Queensknight' + extra_import_names: + - Book of Chivalry + - Word of the Knighthood +- has_accessories: True + has_armour: True + has_weapon: True + item_level: 750 + name: 'Historia' +- has_accessories: True + has_armour: True + has_weapon: True + item_level: 760 + name: 'Augmented Historia' +- has_accessories: True + has_armour: True + has_weapon: False + item_level: 760 + name: 'Babyface Champion' +- has_accessories: False + has_armour: False + has_weapon: True + item_level: 765 + name: 'Babyface Champion' diff --git a/backend/api/management/commands/seed_data/tiers.yml b/backend/api/management/commands/seed_data/tiers.yml index 0e6b596..3c4219e 100644 --- a/backend/api/management/commands/seed_data/tiers.yml +++ b/backend/api/management/commands/seed_data/tiers.yml @@ -1,3 +1,15 @@ +# 7.2 +- raid_gear_name: 'Babyface Champion' + tome_gear_name: 'Augmented Historia' + max_item_level: 765 + name: 'AAC Cruiserweight Tier' + fights: + - 'AAC Cruiserweight M1' + - 'AAC Cruiserweight M2' + - 'AAC Cruiserweight M3' + - 'AAC Cruiserweight M4' + + # 7.0 - raid_gear_name: 'Dark Horse Champion' tome_gear_name: 'Augmented Quetzalli' diff --git a/backend/api/tests/test_import_api_view.py b/backend/api/tests/test_import_api_view.py new file mode 100644 index 0000000..43b9cc4 --- /dev/null +++ b/backend/api/tests/test_import_api_view.py @@ -0,0 +1,48 @@ +from io import StringIO + +from django.core.management import call_command + +from api.models import Gear +from .test_base import SavageAimTestCase +from ..views.base import ImportAPIView + + +class ImportAPIViewTestSuite(SavageAimTestCase): + """ + Ensure that gear is correctly imported by the view for finding its IDs + """ + + def setUp(self): + """ + Call the Gear seed command to prepopulate the DB + """ + call_command('seed', stdout=StringIO()) + self.selection = Gear.objects.all().values('name', 'id', 'extra_import_classes', 'extra_import_names') + + def test_queensknight_gear(self): + names_to_check = [ + 'Queensknight Falchion', + 'Queensknight Bardiche', + 'Queensknight Faussar', + 'Queensknight Gunblade', + 'Queensknight Spear', + 'Queensknight Scythe', + 'Queensknight Baghnakhs', + 'Queensknight Blade', + 'Queensknight Knives', + 'Queensknight Twinfangs', + 'Queensknight Compound Bow', + 'Queensknight Pistol', + 'Queensknight War Quoits', + 'Queensknight Scepter', + 'Queensknight Foil', + 'Queensknight Flat Brush', + 'Queensknight Cane', + 'Queensknight Astrometer', + 'Queensknight Syrinxi', + 'Book of Chivalry', + 'Word of the Knighthood', + ] + expected_id = Gear.objects.get(name='Queensknight').pk + for name in names_to_check: + self.assertEqual(ImportAPIView._get_gear_id(self.selection, name), expected_id) diff --git a/backend/api/tests/test_loot_solver.py b/backend/api/tests/test_loot_solver.py index cf00623..d1be3c7 100644 --- a/backend/api/tests/test_loot_solver.py +++ b/backend/api/tests/test_loot_solver.py @@ -809,6 +809,21 @@ def test_whole_view_split_loot(self): def test_solver_sort_overrides(self): """ Ensure that overriding the solver sort order actually affects the way the members are ordered. + + expected = { + 'earrings': [self.tm5.id, self.tm6.id], + 'necklace': [self.tm1.id, self.tm2.id, self.tm3.id, self.tm4.id, self.tm7.id, self.tm8.id], + 'bracelet': [self.tm3.id, self.tm4.id, self.tm5.id, self.tm6.id], + 'ring': [self.tm5.id, self.tm6.id, self.tm7.id, self.tm8.id], + 'head': [self.tm1.id, self.tm2.id, self.tm3.id, self.tm5.id, self.tm7.id, self.tm8.id], + 'hands': [self.tm1.id, self.tm2.id, self.tm7.id], + 'feet': [self.tm3.id, self.tm4.id, self.tm5.id, self.tm6.id, self.tm8.id], + 'tome-accessory-augment': [self.tm1.id, self.tm1.id, self.tm1.id, self.tm2.id, self.tm2.id, self.tm2.id, self.tm3.id, self.tm3.id, self.tm4.id, self.tm4.id, self.tm5.id, self.tm5.id, self.tm6.id, self.tm6.id, self.tm7.id, self.tm7.id, self.tm7.id, self.tm8.id, self.tm8.id, self.tm8.id], + 'body': [self.tm3.id, self.tm4.id, self.tm5.id, self.tm6.id, self.tm8.id], + 'legs': [self.tm1.id, self.tm2.id, self.tm7.id], + 'tome-armour-augment': [self.tm1.id, self.tm1.id, self.tm2.id, self.tm2.id, self.tm3.id, self.tm3.id, self.tm4.id, self.tm4.id, self.tm4.id, self.tm5.id, self.tm5.id, self.tm6.id, self.tm6.id, self.tm6.id, self.tm7.id, self.tm7.id, self.tm8.id, self.tm8.id], + '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], + } """ # First test while the team has no overrides to ensure the list matches what we expect member_order = LootSolver._get_team_solver_sort_order(self.team) @@ -850,8 +865,8 @@ def test_solver_sort_overrides(self): {'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': True, 'Head': self.tm7.id, 'Hands': self.tm1.id, 'Feet': self.tm6.id, 'Tome Accessory Augment': self.tm4.id}, - {'token': False, '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.tm5.id, 'Hands': None, 'Feet': self.tm3.id, 'Tome Accessory Augment': self.tm2.id}, + {'token': False, 'Head': self.tm8.id, 'Hands': None, 'Feet': self.tm4.id, 'Tome Accessory Augment': self.tm7.id}, {'token': True, 'Head': self.tm1.id, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': self.tm6.id}, ] @@ -1671,12 +1686,12 @@ def test_dev_setup_edgecase_bug_solution(self): } expected = [ - {'token': False, 'Head': 4, 'Hands': 2, 'Feet': 4, 'Tome Accessory Augment': 3}, - {'token': False, 'Head': 3, 'Hands': 4, 'Feet': 2, 'Tome Accessory Augment': 2}, - {'token': True, 'Head': 2, 'Hands': 3, 'Feet': None, 'Tome Accessory Augment': 4}, - {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': 4}, + {'token': False, 'Head': 4, 'Hands': 3, 'Feet': 2, 'Tome Accessory Augment': 4}, + {'token': False, 'Head': 3, 'Hands': 2, 'Feet': 4, 'Tome Accessory Augment': 4}, + {'token': True, 'Head': 2, 'Hands': 4, 'Feet': None, 'Tome Accessory Augment': 3}, {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': 2}, - {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': 4}, + {'token': False, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': 4}, + {'token': True, 'Head': None, 'Hands': None, 'Feet': None, 'Tome Accessory Augment': 2}, ] received = LootSolver._get_handout_data( LootSolver.SECOND_FLOOR_SLOTS, @@ -1725,3 +1740,34 @@ def test_removed_pop_was_none_bug(self): self.assertEqual(len(expected), len(received), received) for i in range(len(expected)): self.assertDictEqual(expected[i], received[i], f'{i+1}/{len(received)}') + + def test_purchases_are_limited_to_tomes_only_in_midtier_fights(self): + """ + There are some errors in the loot solver for fights 2/3 where it could be expecting armour piece buys for the same price as an augment. + """ + weeks = 2 + second_floor_requirements = { + 'head': [1, 2, 3, 4], + 'hands': [1, 2, 5, 6], + 'feet': [5, 6, 7, 8, 3, 4], + 'tome-accessory-augment': [1, 1, 1, 2, 2, 2, 5, 5, 6, 6], + } + second_floor_prios = { + 5: [1, 2], + 4: [5, 6], + 2: [3, 4], + 1: [7, 8], + } + + expected = [ + {'Head': 1, 'Hands': 2, 'Feet': 5, 'Tome Accessory Augment': 6, 'token': True}, + {'Head': 3, 'Hands': 1, 'Feet': 4, 'Tome Accessory Augment': 2, 'token': False}, + {'Head': 2, 'Hands': 5, 'Feet': 6, 'Tome Accessory Augment': 1, 'token': False}, + {'Head': 4, 'Hands': 6, 'Feet': 7, 'Tome Accessory Augment': 1, 'token': True}, + {'Head': None, 'Hands': None, 'Feet': 8, 'Tome Accessory Augment': None, 'token': False}, + {'Head': None, 'Hands': None, 'Feet': 3, 'Tome Accessory Augment': None, 'token': False}, + ] + received = LootSolver._get_handout_data(LootSolver.SECOND_FLOOR_SLOTS, second_floor_requirements, second_floor_prios, LootSolver.SECOND_FLOOR_TOKENS, weeks, False) + self.assertEqual(len(expected), len(received), received) + for i in range(len(expected)): + self.assertDictEqual(expected[i], received[i], f'{i+1}/{len(received)}') diff --git a/backend/api/views/loot_solver.py b/backend/api/views/loot_solver.py index a392bed..63fde0a 100644 --- a/backend/api/views/loot_solver.py +++ b/backend/api/views/loot_solver.py @@ -417,16 +417,27 @@ def _get_handout_data(slots: List[str], requirements: Requirements, prio_bracket except ValueError: pass - # Now remove the item from everyone else + # Now remove the item from everyone else, and check for any now-unique items for other_member_id, other_member_items in potential_loot_members.items(): try: other_member_items.remove(item) + 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])) 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])) + pass + + # Also recalc if someone now has a unique item that they should get + member_items_set = set(other_member_items) + other_set = set() + for other_other_member_id, other_other_member_items in potential_loot_members.items(): + if other_member_id == other_other_member_id: + continue + other_set |= set(other_other_member_items) + uniques = member_items_set - other_set + for unique_item in uniques: + handout_queue.append((other_member_id, unique_item)) # Then do some checking if we need to re_insert the popped member if re_insert: @@ -436,29 +447,34 @@ def _get_handout_data(slots: List[str], requirements: Requirements, prio_bracket # Add the week data to the handouts list handouts.append(week_data) - # Lastly, if weeks % token_count == 0, reduce everyone's requirement by 1 + # Lastly, if weeks % token_count == 0, reduce everyone's requirement by 1 if they can buy an item if weeks % weeks_per_token == 0: - for priority in sorted(prio_brackets): - prio_brackets[priority - 1] = prio_brackets[priority] - - # Need to also remove a loot item for everyone in the priority bracket to keep the requirements info in check - for member_id in prio_brackets[priority]: - # Reverse slots so that we always pop tokens if possible + # Find the members that can buy something and track what they can buy + member_purchases = {} + for remove_prio in sorted(prio_brackets, reverse=True): + member_ids = prio_brackets[remove_prio] + for member_id in member_ids: for slot in remove_slots: + if member_id in requirements[slot] and member_id not in member_purchases: + member_purchases[member_id] = slot + + for purchaser_id, slot in member_purchases.items(): + # Remove the purchaser_id from the item requirements, reduce their priority by one + requirements[slot].remove(purchaser_id) + for remove_prio in sorted(prio_brackets): + if purchaser_id in prio_brackets[remove_prio]: + prio_brackets[remove_prio].remove(purchaser_id) try: - requirements[slot].remove(member_id) - break - except ValueError: - # Keep searching until we find one - pass + prio_brackets[remove_prio - 1].append(purchaser_id) + except KeyError: + prio_brackets[remove_prio - 1] = [purchaser_id] + break - # Remove the 0 key and the highest key because that will have been duplicated + # Remove 0 key, and any empty lists prio_brackets.pop(0, None) - try: - prio_brackets.pop(max(prio_brackets.keys()), None) - except ValueError: - # 0 is also the max and we removed it? - pass + for priority in list(prio_brackets.keys()): + if prio_brackets[priority] == []: + prio_brackets.pop(priority, None) week_data['token'] = True else: week_data['token'] = False diff --git a/backend/api/xivapi_item_search_client.py b/backend/api/xivapi_item_search_client.py index 52d9cd9..44a1344 100644 --- a/backend/api/xivapi_item_search_client.py +++ b/backend/api/xivapi_item_search_client.py @@ -6,10 +6,11 @@ import requests API_KEY = os.environ.get('XIVAPI_KEY', None) +HEADERS = {'User-Agent': 'savageaim.com'} class XIVAPISearchClient: - url = 'https://beta.xivapi.com/api/1/sheet/Item' + url = 'https://v2.xivapi.com/api/sheet/Item' @classmethod def get_item_information(cls, *item_ids: int) -> Dict[str, Dict[str, str]]: @@ -20,7 +21,7 @@ def get_item_information(cls, *item_ids: int) -> Dict[str, Dict[str, str]]: item_ids = set(item_ids) rows = ','.join(str(item_id) for item_id in item_ids) url = f'{cls.url}?rows={rows}&fields=row_id,LevelItem.value,Name' - response = requests.get(url) + response = requests.get(url, headers=HEADERS) response.raise_for_status() for item in response.json().get('rows', []): diff --git a/backend/backend/__init__.py b/backend/backend/__init__.py index c4c2608..4562293 100644 --- a/backend/backend/__init__.py +++ b/backend/backend/__init__.py @@ -2,4 +2,4 @@ from .celery import app as celery_app -VERSION = '20250108.3' +VERSION = '20250325' diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..639f996 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.editorconfig +.eslintrc.js diff --git a/frontend/.env b/frontend/.env index 00891bd..da332fc 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1 @@ -VUE_APP_VERSION="20250108.3" +VUE_APP_VERSION="20250325" diff --git a/frontend/src/components/modals/changelog.vue b/frontend/src/components/modals/changelog.vue index 160b6c4..68ffc70 100644 --- a/frontend/src/components/modals/changelog.vue +++ b/frontend/src/components/modals/changelog.vue @@ -12,39 +12,37 @@

{{ version }}

-
expand_more Loot Solver Algorithm Improvement / Bugfix expand_more
-

Improved Loot Solver algorithm to handle newfound edgecase.

-

Also fixed silly bug in the code that was introduced when fixing the previous Loot Solver bug!

- -

{{ version.split('.')[0] }}.2

-
expand_more Team Delete Bugfix expand_more
-

Quick extra push today to deliver a bugfix to a long hidden bug during Team deletion. Sorry for the inconvenience but thank you for catching it!

-

Thankfully it wasn't an issue for deleting anything, just caused an error at the end of the endpoint call!

- -

{{ version.split('.')[0] }}

-
expand_more Character Verify Bugfix expand_more
-

Fixed a bug during the Character Verification process where it couldn't do cleanup, which first occurred yesterday.

-

For an explanation of the bug and what was done to fix it; +

expand_more FFXIV Patch 7.2 expand_more
+

A new Tier, AAC Cruiserweight Tier, has been added. Please ensure to update your Team to the new Tier to have BIS Table colours display correctly, and best of luck!

+

+ All the gear added with Patch 7.2 has been added to the site, please enjoy updating your BIS Lists for the new Tier!

-

If anyone would like to share feedback / give ideas on this matter, please let me know on Github or in the Discord, or by using the new addition below. Hopefully this is an acceptable solution!

+

The default values for the Item Level filters in BIS pages have been updated to the new range for Cruiserweight. (740 - 765)

-
expand_more Feedback Form expand_more
+
expand_more Loot Solver Bugfixes expand_more

- Added a little Feedback widget for another mechanism of gathering feedback for people not on Github or don't want to join the Discord. + Fixed a bug where the Loot Solver would assume that you could buy armour pieces from 2nd and 3rd fights of a Tier with the same number of clear-tokens as the tome augmentation items.

-

I wasn't planning on releasing this so soon but I wanted the bugfix out ASAP and this was already in the codebase :D

+

Also fixed some other very well hidden bugs, so thank you to everyone for using the Loot page and giving me good data!

+ +
expand_more Work Commencing on SavageAim "v2" expand_more
+

As mentioned before, I need to update some frontend libraries that the site uses for security and updates sakes.

+

If you have any issues with the site, now is definitely the time to make them known, as I'll be trying to fix a bunch of things while keeping the site looking as much the same as possible!

+

Leave your feedback either in the Discord (link at the bottom of the page) or using the nice lil feedback widget at the bottom right of your page!

+

You can track v2 plans at the GitHub Issue here!

+

Thank you all for your continued support, SavageAim wouldn't be half the tool it is without y'all!

diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 04f74e8..4a96d8c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -30,7 +30,7 @@ Sentry.init({ Vue, dsn: 'https://06f41b525a40497a848fb726f6d03244@o242258.ingest.sentry.io/6180221', logErrors: true, - release: 'savageaim@20250108.3', + release: 'savageaim@20250325', integrations: [ Sentry.browserTracingIntegration(), Sentry.replayIntegration(),