supply.py 7.5 KB

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