supply.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. from __future__ import annotations
  2. import collections
  3. import dataclasses
  4. import json
  5. import math
  6. import tomllib
  7. import typing
  8. import tap
  9. import cache
  10. class Args(tap.Tap):
  11. planets: tuple[str, ...]
  12. weight: float
  13. volume: float
  14. def configure(self) -> None:
  15. self.add_argument('planets', nargs='+', metavar='planet') # take planets as positional args instead of flag
  16. def main() -> None:
  17. args = Args().parse_args()
  18. planets = [Planet(fio_burn) for fio_burn in get_fio_burn(args.planets)]
  19. raw_materials: typing.Sequence[Material] = cache.get('https://rest.fnar.net/material/allmaterials')
  20. materials = {mat['Ticker']: mat for mat in raw_materials}
  21. target_days = float('inf')
  22. for planet in planets:
  23. vol_per_day = weight_per_day = 0
  24. for consumption in planet.net_consumption:
  25. ticker = consumption['MaterialTicker']
  26. vol_per_day += materials[ticker]['Volume'] * consumption['net_consumption']
  27. weight_per_day += materials[ticker]['Weight'] * consumption['net_consumption']
  28. days = planet.inventory.get(ticker, 0) / consumption['net_consumption']
  29. if days < target_days:
  30. target_days = days
  31. print(planet.name, f'consumes {vol_per_day:.1f}㎥, {weight_per_day:.1f}t per day')
  32. optimal: dict[str, dict[str, int]] = None # pyright: ignore[reportAssignmentType]
  33. total_weight_used: float = None # pyright: ignore[reportAssignmentType]
  34. total_volume_used: float = None # pyright: ignore[reportAssignmentType]
  35. target_days = round(target_days + 0.05, 1)
  36. load_more = True
  37. while load_more:
  38. buys: dict[str, dict[str, int]] = {}
  39. iteration_weight = iteration_volume = 0
  40. for planet in planets:
  41. buy, weight_used, volume_used = planet.buy_for_target(materials, target_days)
  42. iteration_weight += weight_used
  43. iteration_volume += volume_used
  44. if iteration_weight > args.weight or iteration_volume > args.volume:
  45. load_more = False
  46. break
  47. buys[planet.name] = buy
  48. if load_more:
  49. optimal = buys
  50. total_weight_used = iteration_weight
  51. total_volume_used = iteration_volume
  52. target_days += 0.1
  53. print('supply for', round(target_days, 1), 'days,', end=' ')
  54. print(f'consuming {round(total_weight_used, 1)}t and {round(total_volume_used, 1)}㎥') # pyright: ignore[reportPossiblyUnboundVariable]
  55. for planet in planets:
  56. print('\n' + cyan(planet.name))
  57. for consumption in planet.net_consumption:
  58. ticker = consumption['MaterialTicker']
  59. avail = planet.inventory.get(ticker, 0)
  60. daily_consumption = consumption['net_consumption']
  61. days = avail / daily_consumption
  62. print(f'{ticker:>3}: {avail:5d} ({daily_consumption:8.2f}/d) {days:4.1f} d', end='')
  63. if need := optimal[planet.name].get(ticker): # pyright: ignore[reportOptionalMemberAccess]
  64. print(f' | {need:8.1f}')
  65. else:
  66. print()
  67. combined_buy: dict[str, int] = collections.defaultdict(int)
  68. for buy in optimal.values():
  69. for ticker, amount in buy.items():
  70. combined_buy[ticker] += amount
  71. print(cyan('\nbuy:\n') + json.dumps({
  72. 'actions': [
  73. {'name': 'BuyItems', 'type': 'CX Buy', 'group': 'A1', 'exchange': 'IC1',
  74. 'priceLimits': {}, 'buyPartial': False, 'useCXInv': True},
  75. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  76. 'origin': 'Hortus Station Warehouse', 'dest': 'Configure on Execution'},
  77. ],
  78. 'global': {'name': 'supply ' + ' '.join(args.planets)},
  79. 'groups': [{
  80. 'type': 'Manual', 'name': 'A1', 'materials': {mat: amount for mat, amount in combined_buy.items()}
  81. }],
  82. }))
  83. for planet in planets:
  84. buy = optimal[planet.name]
  85. print(cyan(f'unload {planet.name}:\n') + json.dumps({
  86. 'actions': [
  87. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  88. 'origin': 'Configure on Execution', 'dest': planet.name + ' Base'},
  89. ],
  90. 'global': {'name': 'unload ' + planet.name},
  91. 'groups': [{
  92. 'type': 'Manual', 'name': 'A1', 'materials': {mat: amount for mat, amount in buy.items()}
  93. }],
  94. }))
  95. def get_fio_burn(planet_names: typing.Sequence[str]) -> typing.Iterator[FIOBurn]:
  96. with open('config.toml', 'rb') as f:
  97. config = tomllib.load(f)
  98. planets: list[FIOBurn] = cache.get('https://rest.fnar.net/fioweb/burn/user/' + config['username'],
  99. headers={'Authorization': config['fio_api_key']})
  100. for name in planet_names:
  101. name = name.casefold()
  102. for planet_data in planets:
  103. if name in (planet_data['PlanetName'].casefold(), planet_data['PlanetNaturalId'].casefold()):
  104. assert planet_data['Error'] is None
  105. yield planet_data
  106. break
  107. else:
  108. raise ValueError(name + ' not found')
  109. def cyan(text: str) -> str:
  110. return '\033[36m' + text + '\033[0m'
  111. class FIOBurn(typing.TypedDict):
  112. PlanetName: str
  113. PlanetNaturalId: str
  114. Error: typing.Any
  115. OrderConsumption: list[Amount]
  116. WorkforceConsumption: list[Amount]
  117. Inventory: list[Inventory]
  118. OrderProduction: list[Amount]
  119. @dataclasses.dataclass(init=False, eq=False, slots=True)
  120. class Planet:
  121. name: str
  122. inventory: dict[str, int]
  123. net_consumption: typing.Sequence[Amount]
  124. def __init__(self, fio_burn: FIOBurn) -> None:
  125. self.name = fio_burn['PlanetName'] or fio_burn['PlanetNaturalId']
  126. self.inventory = {item['MaterialTicker']: item['MaterialAmount'] for item in fio_burn['Inventory']}
  127. producing = {item['MaterialTicker']: item for item in fio_burn['OrderProduction']}
  128. self.net_consumption = []
  129. for c in fio_burn['OrderConsumption'] + fio_burn['WorkforceConsumption']:
  130. net = c['DailyAmount']
  131. if production := producing.get(c['MaterialTicker']):
  132. net -= production['DailyAmount']
  133. if net < 0:
  134. continue
  135. c['net_consumption'] = net
  136. self.net_consumption.append(c)
  137. def buy_for_target(self, materials: dict[str, Material], target_days: float) -> tuple[dict[str, int], float, float]:
  138. weight_used = volume_used = 0
  139. buy: dict[str, int] = {}
  140. for consumption in self.net_consumption:
  141. ticker = consumption['MaterialTicker']
  142. avail = self.inventory.get(ticker, 0)
  143. daily_consumption = consumption['net_consumption']
  144. days = avail / daily_consumption
  145. if days < target_days:
  146. buy[ticker] = math.ceil((target_days - days) * daily_consumption)
  147. weight_used += buy[ticker] * materials[ticker]['Weight']
  148. volume_used += buy[ticker] * materials[ticker]['Volume']
  149. return buy, weight_used, volume_used
  150. class Amount(typing.TypedDict):
  151. MaterialTicker: str
  152. DailyAmount: float
  153. net_consumption: float
  154. class Inventory(typing.TypedDict):
  155. MaterialTicker: str
  156. MaterialAmount: int
  157. class Material(typing.TypedDict):
  158. Ticker: str
  159. Weight: float
  160. Volume: float
  161. if __name__ == '__main__':
  162. main()