supply.py 7.2 KB

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