esf-format 1.0.0__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.
- esf_format/__init__.py +104 -0
- esf_format/binary.py +185 -0
- esf_format/canonical.py +285 -0
- esf_format/catalog.py +94 -0
- esf_format/errors.py +28 -0
- esf_format/model.py +69 -0
- esf_format/names.py +33 -0
- esf_format/py.typed +0 -0
- esf_format/resolve.py +293 -0
- esf_format/source.py +126 -0
- esf_format/text.py +354 -0
- esf_format/wire.py +120 -0
- esf_format-1.0.0.dist-info/METADATA +119 -0
- esf_format-1.0.0.dist-info/RECORD +16 -0
- esf_format-1.0.0.dist-info/WHEEL +4 -0
- esf_format-1.0.0.dist-info/licenses/LICENSE +19 -0
esf_format/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Read and write esf/1 (https://github.com/EVEShipFit/esf-format)."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from . import binary, canonical, resolve, source, text
|
|
7
|
+
from .canonical import CanonFit
|
|
8
|
+
from .catalog import (
|
|
9
|
+
AttributeRecord,
|
|
10
|
+
Catalog,
|
|
11
|
+
Kind,
|
|
12
|
+
ModeCatalog,
|
|
13
|
+
MutaplasmidCatalog,
|
|
14
|
+
MutaplasmidRecord,
|
|
15
|
+
TypeRecord,
|
|
16
|
+
)
|
|
17
|
+
from .errors import EsfError
|
|
18
|
+
from .model import Entry, EsfFit, Location, State
|
|
19
|
+
from .names import fold
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AttributeRecord",
|
|
23
|
+
"Catalog",
|
|
24
|
+
"Entry",
|
|
25
|
+
"EsfError",
|
|
26
|
+
"EsfFit",
|
|
27
|
+
"Kind",
|
|
28
|
+
"Location",
|
|
29
|
+
"ModeCatalog",
|
|
30
|
+
"MutaplasmidCatalog",
|
|
31
|
+
"MutaplasmidRecord",
|
|
32
|
+
"State",
|
|
33
|
+
"TypeRecord",
|
|
34
|
+
"fold",
|
|
35
|
+
"from_base64url",
|
|
36
|
+
"is_text",
|
|
37
|
+
"load",
|
|
38
|
+
"save",
|
|
39
|
+
"save_binary",
|
|
40
|
+
"to_base64url",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_BASE64URL = re.compile(r"[A-Za-z0-9_-]*")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_text(data: bytes | str) -> bool:
|
|
47
|
+
"""Whether a document is text, by its first character (§10)."""
|
|
48
|
+
return data[:1] in (b"%", b" ", "%", " ")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _canonical(
|
|
52
|
+
blocks: list[text.FitBlock], catalog: Catalog, given: list[EsfFit] | None = None
|
|
53
|
+
) -> list[CanonFit]:
|
|
54
|
+
fits = resolve.resolve(blocks, catalog)
|
|
55
|
+
if given is not None:
|
|
56
|
+
source.verify(fits, given)
|
|
57
|
+
return [canonical.canonical_fit(f, catalog) for f in fits]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _checked(fits: list[EsfFit], catalog: Catalog) -> list[CanonFit]:
|
|
61
|
+
data, where = source.source(fits, catalog)
|
|
62
|
+
try:
|
|
63
|
+
return _canonical(text.parse(data), catalog, fits)
|
|
64
|
+
except EsfError as e:
|
|
65
|
+
if e.line is None:
|
|
66
|
+
raise
|
|
67
|
+
fit, entry = where[e.line - 1]
|
|
68
|
+
raise EsfError(e.message, fit=fit, entry=entry) from None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load(data: bytes | str, catalog: Catalog) -> list[EsfFit]:
|
|
72
|
+
"""Read a text or binary document, in canonical form."""
|
|
73
|
+
if isinstance(data, str):
|
|
74
|
+
data = data.encode("utf-8", "surrogatepass")
|
|
75
|
+
if not data:
|
|
76
|
+
raise EsfError("the document is empty")
|
|
77
|
+
if is_text(data):
|
|
78
|
+
fits = _canonical(text.parse(data), catalog)
|
|
79
|
+
else:
|
|
80
|
+
fits = _checked(binary.decode(data), catalog)
|
|
81
|
+
return [canonical.to_model(f) for f in fits]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save(fits: list[EsfFit], catalog: Catalog) -> str:
|
|
85
|
+
"""Write fits as canonical text (§8)."""
|
|
86
|
+
return canonical.render(_checked(fits, catalog))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def save_binary(fits: list[EsfFit], catalog: Catalog) -> bytes:
|
|
90
|
+
"""Write fits in the binary form (§10)."""
|
|
91
|
+
return binary.encode([canonical.to_model(f) for f in _checked(fits, catalog)])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def to_base64url(data: bytes) -> str:
|
|
95
|
+
"""The binary form as base64url without padding, for URLs (§10.4)."""
|
|
96
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def from_base64url(encoded: str) -> bytes:
|
|
100
|
+
"""The binary form from base64url without padding (§10.4)."""
|
|
101
|
+
encoded = encoded.strip()
|
|
102
|
+
if not _BASE64URL.fullmatch(encoded) or len(encoded) % 4 == 1:
|
|
103
|
+
raise EsfError("not valid base64url")
|
|
104
|
+
return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
esf_format/binary.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""The binary form (SPEC §10)."""
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from . import wire
|
|
7
|
+
from .model import Entry, EsfFit, Location, State
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
from .errors import EsfError
|
|
11
|
+
from .text import UINT32_MAX
|
|
12
|
+
|
|
13
|
+
STATES = {State.OFF: 1, State.ON: 2, State.HEAT: 3}
|
|
14
|
+
LOCATIONS = {
|
|
15
|
+
Location.HIGH: 1,
|
|
16
|
+
Location.MID: 2,
|
|
17
|
+
Location.LOW: 3,
|
|
18
|
+
Location.RIG: 4,
|
|
19
|
+
Location.SUB: 5,
|
|
20
|
+
Location.SVC: 6,
|
|
21
|
+
Location.CARGO: 7,
|
|
22
|
+
Location.BAY: 8,
|
|
23
|
+
Location.AMMO: 9,
|
|
24
|
+
Location.BOOSTER: 10,
|
|
25
|
+
Location.COMMAND: 11,
|
|
26
|
+
Location.CORPSE: 12,
|
|
27
|
+
Location.DEPOT: 13,
|
|
28
|
+
Location.EXPEDITION: 14,
|
|
29
|
+
Location.FLEET: 15,
|
|
30
|
+
Location.FRIGATE: 16,
|
|
31
|
+
Location.FUEL: 17,
|
|
32
|
+
Location.GAS: 18,
|
|
33
|
+
Location.ICE: 19,
|
|
34
|
+
Location.INFRASTRUCTURE: 20,
|
|
35
|
+
Location.MAINTENANCE: 21,
|
|
36
|
+
Location.MINERAL: 22,
|
|
37
|
+
Location.MINING: 23,
|
|
38
|
+
Location.MOON: 24,
|
|
39
|
+
Location.PLANETARY: 25,
|
|
40
|
+
Location.QUAFE: 26,
|
|
41
|
+
Location.SUBSYSTEM: 27,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _encode_entry(entry: Entry) -> bytes:
|
|
46
|
+
out = []
|
|
47
|
+
if entry.type is not None:
|
|
48
|
+
out.append(wire.uint(1, entry.type))
|
|
49
|
+
if entry.count is not None:
|
|
50
|
+
out.append(wire.uint(2, entry.count))
|
|
51
|
+
if entry.fit_name:
|
|
52
|
+
out.append(wire.record(3, entry.fit_name.encode()))
|
|
53
|
+
if entry.charge is not None:
|
|
54
|
+
out.append(wire.uint(4, entry.charge))
|
|
55
|
+
if entry.charge_count is not None:
|
|
56
|
+
out.append(wire.uint(5, entry.charge_count))
|
|
57
|
+
if entry.mutaplasmid is not None:
|
|
58
|
+
out.append(wire.uint(6, entry.mutaplasmid))
|
|
59
|
+
if entry.overrides:
|
|
60
|
+
out.append(wire.packed_uints(7, list(entry.overrides)))
|
|
61
|
+
out.append(wire.packed_doubles(8, list(entry.overrides.values())))
|
|
62
|
+
if entry.state is not None:
|
|
63
|
+
out.append(wire.uint(9, STATES[entry.state]))
|
|
64
|
+
if entry.location is not None:
|
|
65
|
+
out.append(wire.uint(10, LOCATIONS[entry.location]))
|
|
66
|
+
return b"".join(out)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _encode_fit(fit: EsfFit) -> bytes:
|
|
70
|
+
out = [wire.uint(1, 1)]
|
|
71
|
+
if fit.hull is not None:
|
|
72
|
+
out.append(wire.uint(2, fit.hull))
|
|
73
|
+
if fit.name:
|
|
74
|
+
out.append(wire.record(3, fit.name.encode()))
|
|
75
|
+
if fit.mode is not None:
|
|
76
|
+
out.append(wire.uint(4, fit.mode))
|
|
77
|
+
out += [wire.record(5, _encode_entry(entry)) for entry in fit.entries]
|
|
78
|
+
return b"".join(out)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def encode(fits: list[EsfFit]) -> bytes:
|
|
82
|
+
return b"".join(wire.record(1, _encode_fit(fit)) for fit in fits)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _uint32(value: int, what: str) -> int:
|
|
86
|
+
if value > UINT32_MAX:
|
|
87
|
+
raise EsfError(f"binary: {what} is out of range")
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _string(payload: bytes, what: str) -> str | None:
|
|
92
|
+
try:
|
|
93
|
+
return payload.decode("utf-8") or None
|
|
94
|
+
except UnicodeDecodeError:
|
|
95
|
+
raise EsfError(f"binary: {what} is not valid UTF-8") from None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _enum(numbers: dict[T, int], value: int, what: str) -> T | None:
|
|
99
|
+
if value == 0:
|
|
100
|
+
return None
|
|
101
|
+
for member, number in numbers.items():
|
|
102
|
+
if number == value:
|
|
103
|
+
return member
|
|
104
|
+
raise EsfError(f"binary: unknown {what} value {value}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _not_in_schema(what: str) -> EsfError:
|
|
108
|
+
return EsfError(f"binary: {what} has a field that is not in the schema")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _decode_entry(data: bytes) -> Entry:
|
|
112
|
+
e = Entry(None)
|
|
113
|
+
attributes: list[int] = []
|
|
114
|
+
values: list[float] = []
|
|
115
|
+
for tag, wire_type, value in wire.fields(data):
|
|
116
|
+
match tag, wire_type:
|
|
117
|
+
case 1, wire.VARINT:
|
|
118
|
+
e.type = _uint32(value, "Entry.type")
|
|
119
|
+
case 2, wire.VARINT:
|
|
120
|
+
e.count = _uint32(value, "Entry.count")
|
|
121
|
+
case 3, wire.LEN:
|
|
122
|
+
e.fit_name = _string(value, "Entry.fit_name")
|
|
123
|
+
case 4, wire.VARINT:
|
|
124
|
+
e.charge = _uint32(value, "Entry.charge")
|
|
125
|
+
case 5, wire.VARINT:
|
|
126
|
+
e.charge_count = _uint32(value, "Entry.charge_count")
|
|
127
|
+
case 6, wire.VARINT:
|
|
128
|
+
e.mutaplasmid = _uint32(value, "Entry.mutaplasmid")
|
|
129
|
+
case 7, wire.VARINT:
|
|
130
|
+
attributes.append(_uint32(value, "Entry.override_attributes"))
|
|
131
|
+
case 7, wire.LEN:
|
|
132
|
+
attributes += [
|
|
133
|
+
_uint32(v, "Entry.override_attributes") for v in wire.unpack_uints(value)
|
|
134
|
+
]
|
|
135
|
+
case 8, wire.I64:
|
|
136
|
+
values.append(struct.unpack("<d", value)[0])
|
|
137
|
+
case 8, wire.LEN:
|
|
138
|
+
values += wire.unpack_doubles(value)
|
|
139
|
+
case 9, wire.VARINT:
|
|
140
|
+
e.state = _enum(STATES, value, "State")
|
|
141
|
+
case 10, wire.VARINT:
|
|
142
|
+
e.location = _enum(LOCATIONS, value, "Location")
|
|
143
|
+
case _:
|
|
144
|
+
raise _not_in_schema("Entry")
|
|
145
|
+
if len(attributes) != len(values):
|
|
146
|
+
raise EsfError("binary: override fields have different lengths")
|
|
147
|
+
e.overrides = dict(zip(attributes, values))
|
|
148
|
+
if len(e.overrides) != len(attributes):
|
|
149
|
+
raise EsfError("binary: an attribute is overridden twice")
|
|
150
|
+
return e
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _decode_fit(data: bytes) -> EsfFit:
|
|
154
|
+
fit = EsfFit()
|
|
155
|
+
version = 0
|
|
156
|
+
for tag, wire_type, value in wire.fields(data):
|
|
157
|
+
match tag, wire_type:
|
|
158
|
+
case 1, wire.VARINT:
|
|
159
|
+
version = _uint32(value, "Fit.version")
|
|
160
|
+
case 2, wire.VARINT:
|
|
161
|
+
fit.hull = _uint32(value, "Fit.hull")
|
|
162
|
+
case 3, wire.LEN:
|
|
163
|
+
fit.name = _string(value, "Fit.name")
|
|
164
|
+
case 4, wire.VARINT:
|
|
165
|
+
fit.mode = _uint32(value, "Fit.tactical_mode")
|
|
166
|
+
case 5, wire.LEN:
|
|
167
|
+
fit.entries.append(_decode_entry(value))
|
|
168
|
+
case _:
|
|
169
|
+
raise _not_in_schema("Fit")
|
|
170
|
+
if version != 1:
|
|
171
|
+
raise EsfError(f"binary: unsupported Fit.version {version}")
|
|
172
|
+
return fit
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def decode(data: bytes) -> list[EsfFit]:
|
|
176
|
+
fits = []
|
|
177
|
+
for tag, wire_type, value in wire.fields(data):
|
|
178
|
+
if tag != 1:
|
|
179
|
+
continue
|
|
180
|
+
if wire_type != wire.LEN:
|
|
181
|
+
raise EsfError("binary: Document.fits has the wrong wire type")
|
|
182
|
+
fits.append(_decode_fit(value))
|
|
183
|
+
if not fits:
|
|
184
|
+
raise EsfError("binary: a document holds at least one fit")
|
|
185
|
+
return fits
|
esf_format/canonical.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Canonical form (SPEC §8)."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
|
|
6
|
+
from . import catalog as cat
|
|
7
|
+
from .catalog import AttributeRecord, Catalog, MutaplasmidRecord, TypeRecord
|
|
8
|
+
from .errors import EsfError
|
|
9
|
+
from .model import Entry, EsfFit, Location, State
|
|
10
|
+
from .names import fold, shortest_unique
|
|
11
|
+
from .resolve import (
|
|
12
|
+
DEFAULT_PLACES,
|
|
13
|
+
DRONES,
|
|
14
|
+
FIGHTERS,
|
|
15
|
+
PLUGGED,
|
|
16
|
+
EmptySlot,
|
|
17
|
+
Fit,
|
|
18
|
+
Item,
|
|
19
|
+
default_place,
|
|
20
|
+
mode_name,
|
|
21
|
+
)
|
|
22
|
+
from .text import HOLDS, RACKS, SIGILS, UINT32_MAX, is_count
|
|
23
|
+
|
|
24
|
+
GROUPS = (*RACKS, DRONES, FIGHTERS, Location.CARGO, *HOLDS, *PLUGGED)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CanonLine:
|
|
29
|
+
type: TypeRecord | None
|
|
30
|
+
count: int | None = None
|
|
31
|
+
fit_name: str | None = None
|
|
32
|
+
charge: TypeRecord | None = None
|
|
33
|
+
charge_count: int | None = None
|
|
34
|
+
mutaplasmid: MutaplasmidRecord | None = None
|
|
35
|
+
mutaplasmid_name: str | None = None
|
|
36
|
+
overrides: list[tuple[AttributeRecord, float]] = field(default_factory=list)
|
|
37
|
+
state: str | None = None
|
|
38
|
+
location: str | None = None
|
|
39
|
+
|
|
40
|
+
def body(self) -> str:
|
|
41
|
+
parts = [quote_type(self.type.name) if self.type else "-"]
|
|
42
|
+
if self.fit_name is not None:
|
|
43
|
+
parts.append(quote(self.fit_name))
|
|
44
|
+
if self.charge is not None:
|
|
45
|
+
count = "" if self.charge_count is None else f"{self.charge_count}x "
|
|
46
|
+
parts.append(f":{count}{quote_type(self.charge.name)}")
|
|
47
|
+
if self.mutaplasmid_name is not None:
|
|
48
|
+
parts.append(f"+{quote_type(self.mutaplasmid_name)}")
|
|
49
|
+
if self.overrides:
|
|
50
|
+
pairs = ", ".join(f"{a.name} {number(v)}" for a, v in self.overrides)
|
|
51
|
+
parts.append(f"{{{pairs}}}")
|
|
52
|
+
if self.state is not None:
|
|
53
|
+
parts.append(f"!{self.state}")
|
|
54
|
+
if self.location is not None:
|
|
55
|
+
parts.append(f"@{self.location}")
|
|
56
|
+
return " ".join(parts)
|
|
57
|
+
|
|
58
|
+
def text(self) -> str:
|
|
59
|
+
if self.count is None:
|
|
60
|
+
return self.body()
|
|
61
|
+
return f"{self.count}x {self.body()}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class CanonFit:
|
|
66
|
+
hull: TypeRecord | None
|
|
67
|
+
name: str | None
|
|
68
|
+
mode: TypeRecord | None
|
|
69
|
+
mode_text: str | None
|
|
70
|
+
groups: list[list[CanonLine]]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def lines(self) -> list[CanonLine]:
|
|
74
|
+
return [line for group in self.groups for line in group]
|
|
75
|
+
|
|
76
|
+
def text(self) -> str:
|
|
77
|
+
hull = [quote_type(self.hull.name) if self.hull else "-"]
|
|
78
|
+
if self.name is not None:
|
|
79
|
+
hull.append(quote(self.name))
|
|
80
|
+
if self.mode_text is not None:
|
|
81
|
+
hull.append(f"/{quote_type(self.mode_text)}")
|
|
82
|
+
out = f"%esf/1\n{' '.join(hull)}\n"
|
|
83
|
+
for group in self.groups:
|
|
84
|
+
out += "\n" + "".join(line.text() + "\n" for line in group)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def quote(s: str) -> str:
|
|
89
|
+
return '"' + s.replace('"', '""') + '"'
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def needs_quotes(name: str) -> bool:
|
|
93
|
+
words = name.split(" ")
|
|
94
|
+
return (
|
|
95
|
+
words[0][:1] in ("-", "%", "")
|
|
96
|
+
or is_count(words[0])
|
|
97
|
+
or any(w[:1] in SIGILS or w == "" for w in words)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def quote_type(name: str) -> str:
|
|
102
|
+
return quote(name) if needs_quotes(name) else name
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def number(value: float) -> str:
|
|
106
|
+
"""The shortest decimal, without exponent, that reads back as the same float."""
|
|
107
|
+
if value == 0:
|
|
108
|
+
value = 0.0
|
|
109
|
+
text = repr(value)
|
|
110
|
+
if "e" in text:
|
|
111
|
+
text = format(Decimal(text), "f")
|
|
112
|
+
return text.removesuffix(".0")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _default_state(item: Item) -> str | None:
|
|
116
|
+
"""The state an item has when its line names none; None is active."""
|
|
117
|
+
if item.place in PLUGGED or not item.type.active:
|
|
118
|
+
return "on"
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _line(item: Item, fit: Fit, catalog: Catalog) -> CanonLine:
|
|
123
|
+
line = item.line
|
|
124
|
+
out = CanonLine(item.type)
|
|
125
|
+
|
|
126
|
+
if item.reference is not None:
|
|
127
|
+
out.fit_name = item.reference.name
|
|
128
|
+
|
|
129
|
+
if item.charge is not None:
|
|
130
|
+
out.charge = item.charge
|
|
131
|
+
full = cat.full_load(item.type, item.charge)
|
|
132
|
+
if line.charge_count is not None and line.charge_count != full:
|
|
133
|
+
out.charge_count = line.charge_count
|
|
134
|
+
|
|
135
|
+
overrides = {a.id: (a, v or 0.0) for a, v in item.overrides}
|
|
136
|
+
if item.mutaplasmid is not None:
|
|
137
|
+
out.mutaplasmid = item.mutaplasmid
|
|
138
|
+
own = item.mutaplasmid.type.name
|
|
139
|
+
others = [m.type.name for m in cat.mutaplasmids_of(catalog, item.type)]
|
|
140
|
+
others.remove(own)
|
|
141
|
+
out.mutaplasmid_name = shortest_unique(own, others)
|
|
142
|
+
for attr_id, base in item.mutaplasmid.rolls:
|
|
143
|
+
if attr_id not in overrides:
|
|
144
|
+
overrides[attr_id] = (cat.attribute_of(catalog, attr_id), base)
|
|
145
|
+
out.overrides = sorted(overrides.values(), key=lambda o: (fold(o[0].name), o[0].name))
|
|
146
|
+
|
|
147
|
+
if line.state is not None and line.state != _default_state(item):
|
|
148
|
+
out.state = line.state
|
|
149
|
+
|
|
150
|
+
if item.place != default_place(item.type.kind, fit.cargo_only) and item.place not in RACKS:
|
|
151
|
+
out.location = item.place
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _sort_key(type_: TypeRecord, text: str) -> tuple[str, bytes]:
|
|
156
|
+
return (fold(type_.name), text.encode())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _collapse(lines: list[tuple[bool, CanonLine]]) -> list[CanonLine]:
|
|
160
|
+
"""Merge adjacent identical repeatable lines into one count."""
|
|
161
|
+
out: list[tuple[bool, CanonLine]] = []
|
|
162
|
+
for repeatable, line in lines:
|
|
163
|
+
if out and repeatable and out[-1][0] and out[-1][1].body() == line.body():
|
|
164
|
+
prev = out[-1][1]
|
|
165
|
+
prev.count = (prev.count or 1) + (line.count or 1)
|
|
166
|
+
if prev.count > UINT32_MAX:
|
|
167
|
+
raise EsfError(f"lines collapse into a count of {prev.count}, which is too large")
|
|
168
|
+
continue
|
|
169
|
+
out.append((repeatable, line))
|
|
170
|
+
for _, line in out:
|
|
171
|
+
if line.count == 1:
|
|
172
|
+
line.count = None
|
|
173
|
+
return [line for _, line in out]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _rack(fit: Fit, rack: str, catalog: Catalog) -> list[CanonLine]:
|
|
177
|
+
runs = list(fit.racks.get(rack, []))
|
|
178
|
+
while runs and runs[-1].item is None:
|
|
179
|
+
runs.pop()
|
|
180
|
+
lines = []
|
|
181
|
+
for run in runs:
|
|
182
|
+
if run.item is None:
|
|
183
|
+
line = CanonLine(None, location=rack)
|
|
184
|
+
else:
|
|
185
|
+
line = _line(run.item, fit, catalog)
|
|
186
|
+
line.count = run.length
|
|
187
|
+
lines.append((True, line))
|
|
188
|
+
return _collapse(lines)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _sorted(items: list[Item], fit: Fit, catalog: Catalog) -> list[CanonLine]:
|
|
192
|
+
lines = []
|
|
193
|
+
for item in items:
|
|
194
|
+
line = _line(item, fit, catalog)
|
|
195
|
+
repeatable = not item.stored
|
|
196
|
+
count = item.line.count or 1
|
|
197
|
+
if repeatable:
|
|
198
|
+
line.count = count
|
|
199
|
+
key = _sort_key(item.type, line.body())
|
|
200
|
+
else:
|
|
201
|
+
line.count = None if count == 1 else count
|
|
202
|
+
key = _sort_key(item.type, line.text())
|
|
203
|
+
lines.append((key, repeatable, line))
|
|
204
|
+
lines.sort(key=lambda entry: entry[0])
|
|
205
|
+
return _collapse([(repeatable, line) for _, repeatable, line in lines])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _fighters(items: list[Item], fit: Fit, catalog: Catalog) -> list[CanonLine]:
|
|
209
|
+
tubes = [i for i in items if i.place == FIGHTERS]
|
|
210
|
+
lines = []
|
|
211
|
+
for item in tubes:
|
|
212
|
+
line = _line(item, fit, catalog)
|
|
213
|
+
count = item.line.count
|
|
214
|
+
if count is not None and count != item.type.squadron_size:
|
|
215
|
+
line.count = count
|
|
216
|
+
lines.append(line)
|
|
217
|
+
return lines + _sorted([i for i in items if i.place == Location.BAY], fit, catalog)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _drones(items: list[Item], fit: Fit, catalog: Catalog) -> list[CanonLine]:
|
|
221
|
+
space = _sorted([i for i in items if i.place == DRONES], fit, catalog)
|
|
222
|
+
return space + _sorted([i for i in items if i.place == Location.BAY], fit, catalog)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def canonical_fit(fit: Fit, catalog: Catalog) -> CanonFit:
|
|
226
|
+
groups: dict[str, list[Item]] = {}
|
|
227
|
+
for item in fit.items:
|
|
228
|
+
if isinstance(item, EmptySlot) or item.place in RACKS:
|
|
229
|
+
continue
|
|
230
|
+
group = item.place
|
|
231
|
+
if group == Location.BAY:
|
|
232
|
+
group = DEFAULT_PLACES[item.type.kind]
|
|
233
|
+
groups.setdefault(group, []).append(item)
|
|
234
|
+
|
|
235
|
+
chunks = []
|
|
236
|
+
for group in GROUPS:
|
|
237
|
+
if group in RACKS:
|
|
238
|
+
chunk = _rack(fit, group, catalog)
|
|
239
|
+
elif group == DRONES:
|
|
240
|
+
chunk = _drones(groups.get(group, []), fit, catalog)
|
|
241
|
+
elif group == FIGHTERS:
|
|
242
|
+
chunk = _fighters(groups.get(group, []), fit, catalog)
|
|
243
|
+
else:
|
|
244
|
+
chunk = _sorted(groups.get(group, []), fit, catalog)
|
|
245
|
+
if chunk:
|
|
246
|
+
chunks.append(chunk)
|
|
247
|
+
|
|
248
|
+
mode_text = None
|
|
249
|
+
if fit.hull is not None and fit.mode is not None:
|
|
250
|
+
others = [mode_name(fit.hull, m) for m in cat.modes_of(catalog, fit.hull)]
|
|
251
|
+
own = mode_name(fit.hull, fit.mode)
|
|
252
|
+
others.remove(own)
|
|
253
|
+
mode_text = shortest_unique(own, others)
|
|
254
|
+
|
|
255
|
+
return CanonFit(fit.hull, fit.name, fit.mode, mode_text, chunks)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def render(fits: list[CanonFit]) -> str:
|
|
259
|
+
return "\n".join(fit.text() for fit in fits)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _id(record: TypeRecord | None) -> int | None:
|
|
263
|
+
return None if record is None else record.id
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def to_model(fit: CanonFit) -> EsfFit:
|
|
267
|
+
return EsfFit(
|
|
268
|
+
_id(fit.hull),
|
|
269
|
+
fit.name,
|
|
270
|
+
_id(fit.mode),
|
|
271
|
+
[
|
|
272
|
+
Entry(
|
|
273
|
+
_id(line.type),
|
|
274
|
+
line.count,
|
|
275
|
+
line.fit_name,
|
|
276
|
+
_id(line.charge),
|
|
277
|
+
line.charge_count,
|
|
278
|
+
_id(line.mutaplasmid.type) if line.mutaplasmid else None,
|
|
279
|
+
{a.id: v for a, v in line.overrides},
|
|
280
|
+
State(line.state) if line.state else None,
|
|
281
|
+
Location(line.location) if line.location else None,
|
|
282
|
+
)
|
|
283
|
+
for line in fit.lines
|
|
284
|
+
],
|
|
285
|
+
)
|
esf_format/catalog.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""What esf/1 needs to know about types and attributes (SPEC §5, §6)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Kind(StrEnum):
|
|
11
|
+
"""What a type is (§5)."""
|
|
12
|
+
|
|
13
|
+
HULL = "hull"
|
|
14
|
+
CONTAINER = "container"
|
|
15
|
+
MODE = "mode"
|
|
16
|
+
SUB = "sub"
|
|
17
|
+
HIGH = "high"
|
|
18
|
+
MID = "mid"
|
|
19
|
+
LOW = "low"
|
|
20
|
+
RIG = "rig"
|
|
21
|
+
SVC = "svc"
|
|
22
|
+
DRONE = "drone"
|
|
23
|
+
FIGHTER = "fighter"
|
|
24
|
+
CHARGE = "charge"
|
|
25
|
+
IMPLANT = "implant"
|
|
26
|
+
BOOSTER = "booster"
|
|
27
|
+
OTHER = "other"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TypeRecord:
|
|
32
|
+
id: int
|
|
33
|
+
name: str
|
|
34
|
+
kind: Kind
|
|
35
|
+
active: bool
|
|
36
|
+
capacity: float
|
|
37
|
+
volume: float
|
|
38
|
+
squadron_size: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AttributeRecord:
|
|
43
|
+
id: int
|
|
44
|
+
name: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class MutaplasmidRecord:
|
|
49
|
+
type: TypeRecord
|
|
50
|
+
rolls: tuple[tuple[int, float], ...]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Catalog(Protocol):
|
|
54
|
+
"""Types and attributes, by folded name (§6.10) or by ID."""
|
|
55
|
+
|
|
56
|
+
def type(self, folded: str) -> TypeRecord | None: ...
|
|
57
|
+
|
|
58
|
+
def type_by_id(self, type_id: int) -> TypeRecord | None: ...
|
|
59
|
+
|
|
60
|
+
def attribute(self, folded: str) -> AttributeRecord | None: ...
|
|
61
|
+
|
|
62
|
+
def attribute_by_id(self, attribute_id: int) -> AttributeRecord | None: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ModeCatalog(Catalog, Protocol):
|
|
66
|
+
def modes(self, hull_id: int) -> Sequence[TypeRecord]: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MutaplasmidCatalog(Catalog, Protocol):
|
|
70
|
+
def mutaplasmids(self, type_id: int) -> Sequence[MutaplasmidRecord]: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def modes_of(catalog: Catalog, hull: TypeRecord) -> Sequence[TypeRecord]:
|
|
74
|
+
modes = getattr(catalog, "modes", None)
|
|
75
|
+
return () if modes is None else modes(hull.id)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def mutaplasmids_of(catalog: Catalog, base: TypeRecord) -> Sequence[MutaplasmidRecord]:
|
|
79
|
+
mutaplasmids = getattr(catalog, "mutaplasmids", None)
|
|
80
|
+
return () if mutaplasmids is None else mutaplasmids(base.id)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def attribute_of(catalog: Catalog, attribute_id: int) -> AttributeRecord:
|
|
84
|
+
attribute = catalog.attribute_by_id(attribute_id)
|
|
85
|
+
if attribute is None:
|
|
86
|
+
raise LookupError(f"the catalog has no attribute {attribute_id}")
|
|
87
|
+
return attribute
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def full_load(item: TypeRecord, charge: TypeRecord) -> int | None:
|
|
91
|
+
"""How many charges fit in an item (§6.5)."""
|
|
92
|
+
if item.capacity <= 0 or charge.volume <= 0:
|
|
93
|
+
return None
|
|
94
|
+
return int(Fraction(str(item.capacity)) / Fraction(str(charge.volume)))
|
esf_format/errors.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The error for a document or fit that breaks a rule of esf/1."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EsfError(Exception):
|
|
5
|
+
"""A broken rule, and where: a line of text, or a fit and entry, counting from 1."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
line: int | None = None,
|
|
11
|
+
fit: int | None = None,
|
|
12
|
+
entry: int | None = None,
|
|
13
|
+
):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.line = line
|
|
17
|
+
self.fit = fit
|
|
18
|
+
self.entry = entry
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
if self.fit is not None:
|
|
22
|
+
where = (
|
|
23
|
+
f"fit {self.fit}" if self.entry is None else f"fit {self.fit}, entry {self.entry}"
|
|
24
|
+
)
|
|
25
|
+
return f"{where}: {self.message}"
|
|
26
|
+
if self.line is not None:
|
|
27
|
+
return f"line {self.line}: {self.message}"
|
|
28
|
+
return self.message
|