Skip to content
Merged
5 changes: 5 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**/__pycache__
api/tests
deployment
.coveragerc
.dockerignore
38 changes: 38 additions & 0 deletions backend/api/management/commands/seed_data/gear/7-dt/7.2.yml
Original file line number Diff line number Diff line change
@@ -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'
12 changes: 12 additions & 0 deletions backend/api/management/commands/seed_data/tiers.yml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
48 changes: 48 additions & 0 deletions backend/api/tests/test_import_api_view.py
Original file line number Diff line number Diff line change
@@ -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)
60 changes: 53 additions & 7 deletions backend/api/tests/test_loot_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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},
]

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)}')
62 changes: 39 additions & 23 deletions backend/api/views/loot_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions backend/api/xivapi_item_search_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand All @@ -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', []):
Expand Down
2 changes: 1 addition & 1 deletion backend/backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

from .celery import app as celery_app

VERSION = '20250108.3'
VERSION = '20250325'
4 changes: 4 additions & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
.editorconfig
.eslintrc.js
2 changes: 1 addition & 1 deletion frontend/.env
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VUE_APP_VERSION="20250108.3"
VUE_APP_VERSION="20250325"
Loading
Loading