edapitool 0.6.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- APITool/__init__.py +20 -0
- APITool/auth.py +490 -0
- APITool/capi.py +292 -0
- APITool/cargo.py +148 -0
- APITool/catalog.py +274 -0
- APITool/cli.py +1419 -0
- APITool/constants.py +36 -0
- APITool/construction.py +451 -0
- APITool/daemon.py +668 -0
- APITool/data/fdev_commodities.csv +413 -0
- APITool/data/name_aliases.json +10 -0
- APITool/export.py +987 -0
- APITool/google/__init__.py +10 -0
- APITool/google/exporter.py +517 -0
- APITool/journal.py +429 -0
- APITool/market.py +463 -0
- APITool/matcher.py +279 -0
- APITool/models.py +491 -0
- APITool/service.py +398 -0
- APITool/settings.py +149 -0
- APITool/sheets/__init__.py +41 -0
- APITool/sheets/a1.py +107 -0
- APITool/sheets/destination.py +185 -0
- APITool/sheets/guard.py +77 -0
- APITool/sheets/layout.py +122 -0
- APITool/ship.py +315 -0
- APITool/version.py +130 -0
- APITool/workbook/__init__.py +12 -0
- APITool/workbook/markers.py +440 -0
- APITool/workbook/totals.py +338 -0
- edapitool-0.6.3.dist-info/METADATA +230 -0
- edapitool-0.6.3.dist-info/RECORD +36 -0
- edapitool-0.6.3.dist-info/WHEEL +5 -0
- edapitool-0.6.3.dist-info/entry_points.txt +3 -0
- edapitool-0.6.3.dist-info/licenses/LICENSE +232 -0
- edapitool-0.6.3.dist-info/top_level.txt +1 -0
APITool/constants.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration constants for Elite Dangerous CAPI access.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# CAPI Server URLs
|
|
6
|
+
CAPI_SERVER_LIVE = "https://companion.orerve.net"
|
|
7
|
+
CAPI_SERVER_LEGACY = "https://legacy-companion.orerve.net"
|
|
8
|
+
CAPI_SERVER_BETA = "https://pts-companion.orerve.net"
|
|
9
|
+
|
|
10
|
+
# CAPI Endpoints
|
|
11
|
+
CAPI_PATH_PROFILE = "/profile"
|
|
12
|
+
CAPI_PATH_MARKET = "/market"
|
|
13
|
+
CAPI_PATH_SHIPYARD = "/shipyard"
|
|
14
|
+
CAPI_PATH_FLEETCARRIER = "/fleetcarrier"
|
|
15
|
+
CAPI_PATH_JOURNAL = "/journal"
|
|
16
|
+
CAPI_PATH_COMMUNITYGOALS = "/communitygoals"
|
|
17
|
+
|
|
18
|
+
# Frontier Auth URLs
|
|
19
|
+
AUTH_SERVER = "https://auth.frontierstore.net"
|
|
20
|
+
AUTH_PATH_AUTH = "/auth"
|
|
21
|
+
AUTH_PATH_TOKEN = "/token"
|
|
22
|
+
AUTH_PATH_DECODE = "/decode"
|
|
23
|
+
|
|
24
|
+
# Default timeouts (seconds)
|
|
25
|
+
DEFAULT_TIMEOUT = 10
|
|
26
|
+
FLEETCARRIER_TIMEOUT = 60
|
|
27
|
+
|
|
28
|
+
# Rate limiting
|
|
29
|
+
MIN_QUERY_INTERVAL = 60 # seconds between queries
|
|
30
|
+
FLEETCARRIER_COOLDOWN = 900 # 15 minutes between FC queries
|
|
31
|
+
|
|
32
|
+
# OAuth2 scopes required
|
|
33
|
+
OAUTH_SCOPES = ["capi", "auth"]
|
|
34
|
+
|
|
35
|
+
# Token file location (in user's home directory)
|
|
36
|
+
TOKEN_FILE = ".ed_capi_tokens.json"
|
APITool/construction.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What a colony construction site still needs, read from the game's own journal.
|
|
3
|
+
|
|
4
|
+
Elite Dangerous writes a ``ColonisationConstructionDepot`` event carrying the
|
|
5
|
+
complete state of a construction site: every commodity it wants, how much has
|
|
6
|
+
been handed over, and what it pays per tonne. That is the whole picture, so
|
|
7
|
+
this module does no arithmetic of consequence -- it parses and it tells the
|
|
8
|
+
truth about what it parsed.
|
|
9
|
+
|
|
10
|
+
Two things are worth knowing before reading further, because both shaped the
|
|
11
|
+
design and neither is obvious:
|
|
12
|
+
|
|
13
|
+
**The game states what was delivered.** ``ProvidedAmount`` is the figure a
|
|
14
|
+
tracking spreadsheet wants in its "delivered" column. Deriving it instead --
|
|
15
|
+
required minus remaining -- is possible and unnecessary, and the subtraction
|
|
16
|
+
is one more place to be wrong.
|
|
17
|
+
|
|
18
|
+
**The site is identified by ``MarketID``, not by name.** The same id appears on
|
|
19
|
+
the ``Docked`` event, so a site can be matched exactly. It cannot be *declared*
|
|
20
|
+
that way by a person, who has no way to see it; resolving a human-typed system
|
|
21
|
+
and station to an id is a separate concern and deliberately not this module's
|
|
22
|
+
(see :mod:`APITool.journal` for the dock events that make it possible).
|
|
23
|
+
|
|
24
|
+
No CAPI, no authentication, no cooldown. This is a local file, the same class
|
|
25
|
+
of source as ``Market.json`` and ``Cargo.json`` -- and unlike the fleet
|
|
26
|
+
carrier's manifest, which a probe established cannot be tracked from the
|
|
27
|
+
journal at all (``tests/one-offs/thinking/carrier-delta/poc.py``).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from typing import Iterable, Mapping, Optional
|
|
35
|
+
|
|
36
|
+
from .catalog import CommodityCatalog, normalize, strip_symbol
|
|
37
|
+
|
|
38
|
+
#: How long a site may go unmentioned before it is called "stale".
|
|
39
|
+
#: Not a game rule -- the game exposes no deadline and never marks a lapsed
|
|
40
|
+
#: build failed. 60 days is simply long enough that an active build will have
|
|
41
|
+
#: been visited, and short enough to separate a live effort from one abandoned
|
|
42
|
+
#: last year. Measured on a real journal: active builds were 0-3 days old and
|
|
43
|
+
#: an abandoned one was 303.
|
|
44
|
+
STALE_AFTER_DAYS = 60
|
|
45
|
+
|
|
46
|
+
#: Events that name a place and carry its market id. Docking is NOT required
|
|
47
|
+
#: to learn a name: approaching a settlement, or merely requesting docking,
|
|
48
|
+
#: records it. Measured across a 621-file journal: 2817 ApproachSettlement and
|
|
49
|
+
#: 1982 DockingRequested against 2314 Docked -- so reading only Docked throws
|
|
50
|
+
#: away more sightings than it keeps, and leaves a site the commander flew to
|
|
51
|
+
#: but chose not to land at with no name at all.
|
|
52
|
+
NAMING_EVENTS = (
|
|
53
|
+
"Docked",
|
|
54
|
+
"Undocked",
|
|
55
|
+
"Location",
|
|
56
|
+
"ApproachSettlement",
|
|
57
|
+
"DockingRequested",
|
|
58
|
+
"DockingGranted",
|
|
59
|
+
"DockingDenied",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
#: The journal event carrying a construction site's full state.
|
|
63
|
+
DEPOT_EVENT = "ColonisationConstructionDepot"
|
|
64
|
+
#: Emitted when cargo is handed over. A delta, where the above is the state.
|
|
65
|
+
CONTRIBUTION_EVENT = "ColonisationContribution"
|
|
66
|
+
|
|
67
|
+
#: Station types that identify a construction site, independent of its name.
|
|
68
|
+
#: Both were observed live: a planetary depot and an orbital one, in the same
|
|
69
|
+
#: system. Filtering on this beats looking for "Construction" in the station
|
|
70
|
+
#: name, which is a display string and not a contract.
|
|
71
|
+
DEPOT_STATION_TYPES = frozenset({"PlanetaryConstructionDepot", "SpaceConstructionDepot"})
|
|
72
|
+
|
|
73
|
+
#: The game prefixes a construction site's station name. A person reading their
|
|
74
|
+
#: own spreadsheet writes "Badeaux Nutrition Centre"; the journal says
|
|
75
|
+
#: "Planetary Construction Site: Badeaux Nutrition Centre". Any match against a
|
|
76
|
+
#: human-typed name has to survive that.
|
|
77
|
+
NAME_PREFIXES = (
|
|
78
|
+
"Planetary Construction Site:",
|
|
79
|
+
"Orbital Construction Site:",
|
|
80
|
+
"Construction Site:",
|
|
81
|
+
# A colonisation ship -- the first structure of a build -- names itself
|
|
82
|
+
# with a game localization key rather than a display string:
|
|
83
|
+
# "$EXT_PANEL_ColonisationShip; Naim Territories"
|
|
84
|
+
# Observed live on three sites. Left unstripped, a person could never type
|
|
85
|
+
# a name that matched it.
|
|
86
|
+
"$EXT_PANEL_ColonisationShip;",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _as_int(value: object, default: int = 0) -> int:
|
|
91
|
+
try:
|
|
92
|
+
return int(value) # type: ignore[arg-type]
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
return default
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_timestamp(raw: object) -> Optional[datetime]:
|
|
98
|
+
if not raw:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
text = str(raw).replace("Z", "+00:00")
|
|
102
|
+
parsed = datetime.fromisoformat(text)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return None
|
|
105
|
+
if parsed.tzinfo is None:
|
|
106
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
107
|
+
return parsed
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def station_key(name: object) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Normalize a station name so a person's version matches the game's.
|
|
113
|
+
|
|
114
|
+
Strips the construction-site prefix before normalizing, so that
|
|
115
|
+
"Badeaux Nutrition Centre" and
|
|
116
|
+
"Planetary Construction Site: Badeaux Nutrition Centre"
|
|
117
|
+
reduce to the same key. Returns "" for nothing usable.
|
|
118
|
+
"""
|
|
119
|
+
text = str(name or "").strip()
|
|
120
|
+
for prefix in NAME_PREFIXES:
|
|
121
|
+
if text.lower().startswith(prefix.lower()):
|
|
122
|
+
text = text[len(prefix):].strip()
|
|
123
|
+
break
|
|
124
|
+
return normalize(text)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class ConstructionResource:
|
|
129
|
+
"""One commodity a site wants, and how much of it has arrived."""
|
|
130
|
+
|
|
131
|
+
symbol: str
|
|
132
|
+
name: str
|
|
133
|
+
required: int = 0
|
|
134
|
+
provided: int = 0
|
|
135
|
+
payment: int = 0
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def key(self) -> str:
|
|
139
|
+
"""The normalized display name, for matching against a sheet row."""
|
|
140
|
+
return normalize(self.name)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def remaining(self) -> int:
|
|
144
|
+
"""Still to deliver. Never negative: over-delivery is not a debt."""
|
|
145
|
+
return max(0, self.required - self.provided)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def satisfied(self) -> bool:
|
|
149
|
+
return self.provided >= self.required
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def coverage(self) -> float:
|
|
153
|
+
"""Fraction delivered, clamped to [0, 1]. Zero required reads as done."""
|
|
154
|
+
if self.required <= 0:
|
|
155
|
+
return 1.0
|
|
156
|
+
return max(0.0, min(1.0, self.provided / self.required))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class ConstructionSite:
|
|
161
|
+
"""A snapshot of one construction site's requirements."""
|
|
162
|
+
|
|
163
|
+
market_id: Optional[int]
|
|
164
|
+
timestamp: Optional[datetime] = None
|
|
165
|
+
progress: float = 0.0
|
|
166
|
+
complete: bool = False
|
|
167
|
+
failed: bool = False
|
|
168
|
+
resources: tuple[ConstructionResource, ...] = field(default_factory=tuple)
|
|
169
|
+
|
|
170
|
+
def __len__(self) -> int:
|
|
171
|
+
return len(self.resources)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def total_required(self) -> int:
|
|
175
|
+
return sum(r.required for r in self.resources)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def total_provided(self) -> int:
|
|
179
|
+
return sum(r.provided for r in self.resources)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def total_remaining(self) -> int:
|
|
183
|
+
return sum(r.remaining for r in self.resources)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def measured_progress(self) -> float:
|
|
187
|
+
"""
|
|
188
|
+
Progress computed from the resource rows, as a cross-check.
|
|
189
|
+
|
|
190
|
+
The game reports ``ConstructionProgress`` directly and it was measured
|
|
191
|
+
to be a plain tonnage ratio -- 5168/8517 = 0.6068, matching exactly.
|
|
192
|
+
This recomputes it so a caller can notice if that ever stops being
|
|
193
|
+
true, rather than assuming the relationship holds forever.
|
|
194
|
+
"""
|
|
195
|
+
required = self.total_required
|
|
196
|
+
if required <= 0:
|
|
197
|
+
return 1.0 if self.complete else 0.0
|
|
198
|
+
return self.total_provided / required
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def outstanding(self) -> tuple[ConstructionResource, ...]:
|
|
202
|
+
"""Only what is still owed, in descending order of what is missing."""
|
|
203
|
+
return tuple(
|
|
204
|
+
sorted(
|
|
205
|
+
(r for r in self.resources if r.remaining > 0),
|
|
206
|
+
key=lambda r: r.remaining,
|
|
207
|
+
reverse=True,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def age_days(self, now: Optional[datetime] = None) -> Optional[int]:
|
|
212
|
+
"""Days since the game last reported this site's state."""
|
|
213
|
+
if self.timestamp is None:
|
|
214
|
+
return None
|
|
215
|
+
reference = now or datetime.now(timezone.utc)
|
|
216
|
+
return max(0, (reference - self.timestamp).days)
|
|
217
|
+
|
|
218
|
+
def status(
|
|
219
|
+
self, now: Optional[datetime] = None, stale_after_days: int = STALE_AFTER_DAYS
|
|
220
|
+
) -> str:
|
|
221
|
+
"""
|
|
222
|
+
One word for what this site is: complete, failed, stale, or active.
|
|
223
|
+
|
|
224
|
+
**"stale" is an inference and the others are facts**, which is why it is
|
|
225
|
+
worded that way rather than as "expired" or "abandoned". A colonisation
|
|
226
|
+
deadline that passes does NOT set ``ConstructionFailed``: measured
|
|
227
|
+
across a 621-file journal, a site left at 15.2% and untouched for 303
|
|
228
|
+
days still reports ``failed: False``. The flag exists, the game simply
|
|
229
|
+
never sets it, so silence cannot be read as "still going".
|
|
230
|
+
|
|
231
|
+
All the tool can honestly say is how long it has been since the game
|
|
232
|
+
last mentioned the site. A caller that wants to hide such sites should
|
|
233
|
+
do so on that basis and say so, rather than claiming a verdict the
|
|
234
|
+
journal never gave.
|
|
235
|
+
"""
|
|
236
|
+
if self.failed:
|
|
237
|
+
return "failed"
|
|
238
|
+
if self.complete:
|
|
239
|
+
return "complete"
|
|
240
|
+
age = self.age_days(now)
|
|
241
|
+
if age is not None and age >= stale_after_days:
|
|
242
|
+
return "stale"
|
|
243
|
+
return "active"
|
|
244
|
+
|
|
245
|
+
def find(self, name: object) -> Optional[ConstructionResource]:
|
|
246
|
+
"""Look a commodity up by display name, however it is capitalised."""
|
|
247
|
+
wanted = normalize(name)
|
|
248
|
+
for resource in self.resources:
|
|
249
|
+
if resource.key == wanted:
|
|
250
|
+
return resource
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _resource_from_journal(
|
|
255
|
+
entry: Mapping, catalog: Optional[CommodityCatalog] = None
|
|
256
|
+
) -> ConstructionResource:
|
|
257
|
+
symbol = strip_symbol(entry.get("Name"))
|
|
258
|
+
# Prefer the localised name the game supplies; fall back to the catalog,
|
|
259
|
+
# then to the bare symbol, so an unrecognised commodity still appears
|
|
260
|
+
# rather than vanishing from a total the sheet depends on.
|
|
261
|
+
name = str(entry.get("Name_Localised") or "").strip()
|
|
262
|
+
if not name and catalog is not None:
|
|
263
|
+
commodity = catalog.by_symbol(symbol) if hasattr(catalog, "by_symbol") else None
|
|
264
|
+
name = getattr(commodity, "display_name", "") or ""
|
|
265
|
+
return ConstructionResource(
|
|
266
|
+
symbol=symbol,
|
|
267
|
+
name=name or symbol,
|
|
268
|
+
required=_as_int(entry.get("RequiredAmount")),
|
|
269
|
+
provided=_as_int(entry.get("ProvidedAmount")),
|
|
270
|
+
payment=_as_int(entry.get("Payment")),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def from_journal(
|
|
275
|
+
event: Mapping, catalog: Optional[CommodityCatalog] = None
|
|
276
|
+
) -> ConstructionSite:
|
|
277
|
+
"""Build a site snapshot from one ``ColonisationConstructionDepot`` event."""
|
|
278
|
+
entries = event.get("ResourcesRequired") or []
|
|
279
|
+
resources = tuple(
|
|
280
|
+
_resource_from_journal(entry, catalog)
|
|
281
|
+
for entry in entries
|
|
282
|
+
if isinstance(entry, Mapping)
|
|
283
|
+
)
|
|
284
|
+
return ConstructionSite(
|
|
285
|
+
market_id=_as_int(event.get("MarketID"), default=0) or None,
|
|
286
|
+
timestamp=_parse_timestamp(event.get("timestamp")),
|
|
287
|
+
progress=float(event.get("ConstructionProgress") or 0.0),
|
|
288
|
+
complete=bool(event.get("ConstructionComplete")),
|
|
289
|
+
failed=bool(event.get("ConstructionFailed")),
|
|
290
|
+
resources=resources,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True)
|
|
295
|
+
class SiteLocation:
|
|
296
|
+
"""Where a construction site is, and what it is and was called."""
|
|
297
|
+
|
|
298
|
+
market_id: int
|
|
299
|
+
station: str = ""
|
|
300
|
+
system: str = ""
|
|
301
|
+
station_type: str = ""
|
|
302
|
+
#: Every earlier name this market id has carried, oldest first.
|
|
303
|
+
#: Sites really are renamed mid-build, and really do drop the
|
|
304
|
+
#: "Planetary Construction Site: " prefix on completion -- measured on one
|
|
305
|
+
#: market id that went "Abara Drilling Enterprise" -> "Beginning" ->
|
|
306
|
+
#: "Beginning" (unprefixed) over two days, keeping its id throughout.
|
|
307
|
+
previous_names: tuple[str, ...] = ()
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def short_station(self) -> str:
|
|
311
|
+
"""The station name without the game's construction-site prefix."""
|
|
312
|
+
text = self.station.strip()
|
|
313
|
+
for prefix in NAME_PREFIXES:
|
|
314
|
+
if text.lower().startswith(prefix.lower()):
|
|
315
|
+
return text[len(prefix):].strip()
|
|
316
|
+
return text
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def former_names(self) -> tuple[str, ...]:
|
|
320
|
+
"""Earlier names, prefix-stripped, excluding the current one.
|
|
321
|
+
|
|
322
|
+
A build renamed halfway leaves a spreadsheet declared under the old
|
|
323
|
+
name matching nothing, with no indication why. Keeping the history is
|
|
324
|
+
what lets a declaration go on working across a rename.
|
|
325
|
+
"""
|
|
326
|
+
current = station_key(self.station)
|
|
327
|
+
out, seen = [], {current}
|
|
328
|
+
for name in self.previous_names:
|
|
329
|
+
key = station_key(name)
|
|
330
|
+
if key and key not in seen:
|
|
331
|
+
seen.add(key)
|
|
332
|
+
stripped = name
|
|
333
|
+
for prefix in NAME_PREFIXES:
|
|
334
|
+
if stripped.lower().startswith(prefix.lower()):
|
|
335
|
+
stripped = stripped[len(prefix):].strip()
|
|
336
|
+
break
|
|
337
|
+
out.append(stripped)
|
|
338
|
+
return tuple(out)
|
|
339
|
+
|
|
340
|
+
def answers_to(self, name: object) -> bool:
|
|
341
|
+
"""Whether this site has EVER been called `name`."""
|
|
342
|
+
key = station_key(name)
|
|
343
|
+
if not key:
|
|
344
|
+
return False
|
|
345
|
+
return key == station_key(self.station) or any(
|
|
346
|
+
key == station_key(n) for n in self.previous_names
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def describe(self) -> str:
|
|
351
|
+
if not self.station and not self.system:
|
|
352
|
+
return ""
|
|
353
|
+
return f"{self.station} / {self.system}".strip(" /")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def locations_from_events(
|
|
357
|
+
events: Iterable[Mapping], market_ids: Optional[Iterable[int]] = None
|
|
358
|
+
) -> dict[int, SiteLocation]:
|
|
359
|
+
"""
|
|
360
|
+
Map ``MarketID`` to the name and system of the place it identifies.
|
|
361
|
+
|
|
362
|
+
The depot event carries a site's contents but never its name; the ``Docked``
|
|
363
|
+
event carries the name but not the contents. They share the market id, so
|
|
364
|
+
joining them turns an opaque integer into something a person recognises --
|
|
365
|
+
which matters in both directions. Reporting a site as "4312376579" is
|
|
366
|
+
useless to a reader, and a person declaring one in a spreadsheet can only
|
|
367
|
+
type the name.
|
|
368
|
+
|
|
369
|
+
**This function does not decide what counts as a construction site**, and
|
|
370
|
+
an earlier version that did was wrong in a way worth recording. It filtered
|
|
371
|
+
docks by ``StationType``, accepting only the two depot types -- which
|
|
372
|
+
silently hid three real sites, because a colonisation ship docks as a plain
|
|
373
|
+
``SurfaceStation`` and is indistinguishable by type from any surface port.
|
|
374
|
+
The symptom was three builds reported as "(name unknown)" at every scan
|
|
375
|
+
depth, which reads as missing data rather than as a filter excluding it.
|
|
376
|
+
|
|
377
|
+
So identity comes from the depot events, which is the only source that
|
|
378
|
+
actually knows; pass their market ids as ``market_ids`` and this resolves
|
|
379
|
+
names for exactly those. With no ids it resolves every dock it sees.
|
|
380
|
+
"""
|
|
381
|
+
wanted = set(market_ids) if market_ids is not None else None
|
|
382
|
+
found: dict[int, SiteLocation] = {}
|
|
383
|
+
for event in events:
|
|
384
|
+
if not isinstance(event, Mapping) or event.get("event") not in NAMING_EVENTS:
|
|
385
|
+
continue
|
|
386
|
+
market_id = _as_int(event.get("MarketID"), default=0)
|
|
387
|
+
if not market_id or (wanted is not None and market_id not in wanted):
|
|
388
|
+
continue
|
|
389
|
+
# ApproachSettlement names the place in `Name`; the docking events use
|
|
390
|
+
# `StationName`. Same fact, two spellings.
|
|
391
|
+
name = str(event.get("StationName") or event.get("Name") or "")
|
|
392
|
+
if not name:
|
|
393
|
+
continue
|
|
394
|
+
history = found[market_id].previous_names if market_id in found else ()
|
|
395
|
+
earlier = found[market_id].station if market_id in found else ""
|
|
396
|
+
if earlier and earlier != name and earlier not in history:
|
|
397
|
+
history = history + (earlier,)
|
|
398
|
+
# MERGE, never replace. Only some naming events carry a system or a
|
|
399
|
+
# station type -- ApproachSettlement and DockingRequested give a name
|
|
400
|
+
# and nothing else -- so overwriting wholesale erases fields a richer
|
|
401
|
+
# earlier event had already supplied. Taking the latest event and
|
|
402
|
+
# letting it null out the system is how four sites lost theirs.
|
|
403
|
+
previous = found.get(market_id)
|
|
404
|
+
found[market_id] = SiteLocation(
|
|
405
|
+
market_id=market_id,
|
|
406
|
+
station=name,
|
|
407
|
+
system=str(event.get("StarSystem") or "") or (previous.system if previous else ""),
|
|
408
|
+
station_type=(
|
|
409
|
+
str(event.get("StationType") or "")
|
|
410
|
+
or (previous.station_type if previous else "")
|
|
411
|
+
),
|
|
412
|
+
previous_names=history,
|
|
413
|
+
)
|
|
414
|
+
return found
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def sites_from_events(
|
|
418
|
+
events: Iterable[Mapping], catalog: Optional[CommodityCatalog] = None
|
|
419
|
+
) -> dict[int, ConstructionSite]:
|
|
420
|
+
"""
|
|
421
|
+
The latest state of every construction site in a run of journal events.
|
|
422
|
+
|
|
423
|
+
Keyed by ``MarketID`` and last-one-wins, because the event carries the
|
|
424
|
+
site's whole state rather than a delta -- a commander bouncing between two
|
|
425
|
+
builds emits interleaved events for both, and each must end up with its own
|
|
426
|
+
most recent snapshot rather than a merge of the two.
|
|
427
|
+
"""
|
|
428
|
+
sites: dict[int, ConstructionSite] = {}
|
|
429
|
+
for event in events:
|
|
430
|
+
if not isinstance(event, Mapping) or event.get("event") != DEPOT_EVENT:
|
|
431
|
+
continue
|
|
432
|
+
site = from_journal(event, catalog)
|
|
433
|
+
if site.market_id is not None:
|
|
434
|
+
sites[site.market_id] = site
|
|
435
|
+
return sites
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
__all__ = [
|
|
439
|
+
"CONTRIBUTION_EVENT",
|
|
440
|
+
"NAMING_EVENTS",
|
|
441
|
+
"STALE_AFTER_DAYS",
|
|
442
|
+
"SiteLocation",
|
|
443
|
+
"locations_from_events",
|
|
444
|
+
"DEPOT_EVENT",
|
|
445
|
+
"DEPOT_STATION_TYPES",
|
|
446
|
+
"ConstructionResource",
|
|
447
|
+
"ConstructionSite",
|
|
448
|
+
"from_journal",
|
|
449
|
+
"sites_from_events",
|
|
450
|
+
"station_key",
|
|
451
|
+
]
|