Skip to content

habs

Data classes and query modules for space bodies, factions, settlements, and hab sites.

tiargus.habs

Data models and SavegameModules for the space colonisation layer.

Provides dataclasses that represent colonisation game entities:

  • Base — a hab (Outpost/Settlement/Colony surface base, or orbital station) including its installed modules.
  • HabBuilding — an individual building (in-game "hab module") installed in a hab sector.
  • HabSite — a potential hab location with daily resource income.

Each colonisation dataclass has a corresponding SavegameModule that parses it from the raw gamestate:

  • BaseModule (key: "bases") — parses every hab plus its modules.
  • HabSiteModule (key: "hab_sites") — depends on SpaceBodyModule, BaseModule, and FactionModule; uses the player faction's intel to filter prospected_hab_sites.

Base dataclass

A hab — a surface base (Outpost/Settlement/Colony) or an orbital station.

Surface bases occupy a hab site; orbital stations do not (hab_site_id is None). Use hab_type to distinguish them.

Attributes:

Name Type Description
id int

Numeric game-state ID of the hab entity.

display_name str

Base name as shown in-game.

template_name str

Internal template identifier; falls back to display_name when absent.

hab_site_id Optional[int]

ID of the HabSite this base occupies, or None for orbital stations not tied to a surface site.

faction_id int

ID of the Faction that owns this base.

tier int

Hab tier — 1 (Outpost), 2 (Settlement), or 3 (Colony).

hab_type str

"Base" for a surface base or "Station" for an orbital.

modules list[HabBuilding]

Installed HabBuilding buildings (in-game "hab modules"), ordered by (sector_num, slot). Empty slots are excluded.

mine_tier Optional[int]

Tier (13) of the base's highest powered mining complex (see MINING_COMPLEX_TIERS), or None if it has no powered mining complex. Unpowered mining complexes do not count.

Source code in src/tiargus/habs.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
@dataclass
class Base:
    """A hab — a surface base (Outpost/Settlement/Colony) or an orbital station.

    Surface bases occupy a hab site; orbital stations do not
    (``hab_site_id`` is ``None``).  Use ``hab_type`` to distinguish them.

    Attributes:
        id: Numeric game-state ID of the hab entity.
        display_name: Base name as shown in-game.
        template_name: Internal template identifier; falls back to
            ``display_name`` when absent.
        hab_site_id: ID of the [HabSite][tiargus.habs.HabSite] this base
            occupies, or ``None`` for orbital stations not tied to a surface site.
        faction_id: ID of the [Faction][tiargus.factions.Faction] that owns this base.
        tier: Hab tier — ``1`` (Outpost), ``2`` (Settlement), or ``3`` (Colony).
        hab_type: ``"Base"`` for a surface base or ``"Station"`` for an orbital.
        modules: Installed [HabBuilding][tiargus.habs.HabBuilding]
            buildings (in-game "hab modules"), ordered by ``(sector_num, slot)``.
            Empty slots are excluded.
        mine_tier: Tier (``1``–``3``) of the base's highest *powered* mining
            complex (see ``MINING_COMPLEX_TIERS``), or ``None`` if it has no
            powered mining complex.  Unpowered mining complexes do not count.
    """

    id: int
    display_name: str
    template_name: str
    hab_site_id: Optional[int]
    faction_id: int
    tier: int
    hab_type: str
    modules: list[HabBuilding]
    mine_tier: Optional[int]

BaseModule

Bases: SavegameModule

Parses every hab — surface bases and orbital stations — with its modules.

Surface bases reference a hab site (hab_site_id); orbital stations do not (hab_site_id is None). Callers that only want surface bases can filter on hab_type == "Base" or on a non-None hab_site_id.

Each hab's installed modules are read from the save's sector and module states and attached to the Base as its modules list, ordered by (sector_num, slot). Empty slots are excluded.

Source code in src/tiargus/habs.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
class BaseModule(SavegameModule):
    """Parses every hab — surface bases and orbital stations — with its modules.

    Surface bases reference a hab site (``hab_site_id``); orbital stations do
    not (``hab_site_id`` is ``None``).  Callers that only want surface bases can
    filter on ``hab_type == "Base"`` or on a non-``None`` ``hab_site_id``.

    Each hab's installed modules are read from the save's sector and module
    states and attached to the [Base][tiargus.habs.Base] as its ``modules``
    list, ordered by ``(sector_num, slot)``.  Empty slots are excluded.
    """

    KEY = "bases"

    def __init__(self) -> None:
        self._bases: dict[int, Base] = {}

    @property
    def bases(self) -> dict[int, Base]:
        """All habs (surface bases and stations) keyed by their numeric game-state ID."""
        return self._bases

    def parse(self, gs: dict, modules: dict[str, SavegameModule]) -> None:
        modules_by_hab = self._modules_by_hab(gs)
        for item in gs[GS_HAB]:
            hid = item["Key"]["value"]
            hab = item["Value"]
            hab_site_ref = hab.get("habSite")
            hab_site_id = (
                hab_site_ref["value"] if isinstance(hab_site_ref, dict) else None
            )
            hab_modules = modules_by_hab.get(hid, [])
            mine_tiers = [
                MINING_COMPLEX_TIERS[m.template_name]
                for m in hab_modules
                if m.powered and m.template_name in MINING_COMPLEX_TIERS
            ]
            self._bases[hid] = Base(
                id=hid,
                display_name=hab["displayName"],
                template_name=hab.get("templateName") or hab["displayName"],
                hab_site_id=hab_site_id,
                faction_id=hab["faction"]["value"],
                tier=int(hab.get("tier", 1)),
                hab_type=hab.get("habType", ""),
                modules=hab_modules,
                mine_tier=max(mine_tiers) if mine_tiers else None,
            )

    @staticmethod
    def _modules_by_hab(gs: dict) -> dict[int, list[HabBuilding]]:
        """Group built hab buildings by their owning hab, ordered by sector/slot.

        Walks the save's sector states (each tied to a hab) and hab-module
        states, skipping empty slots (entries with an empty ``templateName``).
        """
        raw_modules = {
            item["Key"]["value"]: item["Value"] for item in gs.get(GS_HAB_MODULE, [])
        }
        by_hab: dict[int, list[HabBuilding]] = {}
        for item in gs.get(GS_SECTOR, []):
            sector = item["Value"]
            hab_ref = sector.get("hab")
            if not isinstance(hab_ref, dict) or hab_ref.get("value") is None:
                continue
            hab_id = hab_ref["value"]
            sector_num = int(sector.get("sectorNum", 0))
            for ref in sector.get("habModules", []):
                mid = ref.get("value")
                mod = raw_modules.get(mid)
                if mod is None or not mod.get("templateName"):
                    continue  # missing entry or empty slot
                by_hab.setdefault(hab_id, []).append(
                    HabBuilding(
                        id=mid,
                        template_name=mod["templateName"],
                        display_name=mod.get("displayName") or "",
                        sector_num=sector_num,
                        slot=int(mod.get("slot", 0)),
                        construction_completed=bool(mod.get("constructionCompleted")),
                        powered=bool(mod.get("powered")),
                        destroyed=bool(mod.get("destroyed")),
                    )
                )
        for mods in by_hab.values():
            mods.sort(key=lambda m: (m.sector_num, m.slot))
        return by_hab

bases property

All habs (surface bases and stations) keyed by their numeric game-state ID.

__init_subclass__(**kwargs)

Register subclasses in _REGISTRY at definition time.

Raises:

Type Description
TypeError

If a subclass does not define KEY.

Source code in src/tiargus/savegame.py
141
142
143
144
145
146
147
148
149
150
def __init_subclass__(cls, **kwargs: object) -> None:
    """Register subclasses in ``_REGISTRY`` at definition time.

    Raises:
        TypeError: If a subclass does not define ``KEY``.
    """
    super().__init_subclass__(**kwargs)
    if not hasattr(cls, "KEY"):
        raise TypeError(f"{cls.__name__} must define KEY as a class variable")
    SavegameModule._REGISTRY[cls.KEY] = cls

requires() classmethod

Return the KEYs of modules that must be parsed before this one.

Override this in subclasses that need data from other modules. TerraInvictaSave resolves and parses all declared dependencies (and their transitive dependencies) before calling parse() on this module.

Returns:

Type Description
list[str]

A list of KEY strings. The default implementation returns an

list[str]

empty list (no dependencies).

Source code in src/tiargus/savegame.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
@classmethod
def requires(cls) -> list[str]:
    """Return the KEYs of modules that must be parsed before this one.

    Override this in subclasses that need data from other modules.
    ``TerraInvictaSave`` resolves and parses all declared dependencies
    (and their transitive dependencies) before calling ``parse()`` on this
    module.

    Returns:
        A list of ``KEY`` strings.  The default implementation returns an
        empty list (no dependencies).
    """
    return []

HabBuilding dataclass

An individual building installed in one slot of a hab sector.

Terra Invicta calls these "hab modules" in-game; the class is named HabBuilding to avoid confusion with the *Module SavegameModule parsers — it is a game entity, not a parser.

Only built buildings are represented — empty slots (where the save records an empty templateName) are excluded when a Base is parsed.

Attributes:

Name Type Description
id int

Numeric game-state ID of the building.

template_name str

Internal building type (e.g. "PlatformCore"). Always non-empty for a parsed building.

display_name str

Human-readable building name (e.g. "Platform Core"); "" when the save records no name.

sector_num int

Index of the parent sector within the hab.

slot int

Slot index within the sector.

construction_completed bool

False while the building is still being built.

powered bool

False if the building's power is cut or it is destroyed.

destroyed bool

True if the building has been destroyed in combat.

Source code in src/tiargus/habs.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
@dataclass
class HabBuilding:
    """An individual building installed in one slot of a hab sector.

    Terra Invicta calls these "hab modules" in-game; the class is named
    ``HabBuilding`` to avoid confusion with the ``*Module``
    [SavegameModule][tiargus.savegame.SavegameModule] parsers — it is a
    game entity, not a parser.

    Only built buildings are represented — empty slots (where the save records
    an empty ``templateName``) are excluded when a
    [Base][tiargus.habs.Base] is parsed.

    Attributes:
        id: Numeric game-state ID of the building.
        template_name: Internal building type (e.g. ``"PlatformCore"``).  Always
            non-empty for a parsed building.
        display_name: Human-readable building name (e.g. ``"Platform Core"``);
            ``""`` when the save records no name.
        sector_num: Index of the parent sector within the hab.
        slot: Slot index within the sector.
        construction_completed: ``False`` while the building is still being built.
        powered: ``False`` if the building's power is cut or it is destroyed.
        destroyed: ``True`` if the building has been destroyed in combat.
    """

    id: int
    template_name: str
    display_name: str
    sector_num: int
    slot: int
    construction_completed: bool
    powered: bool
    destroyed: bool

HabSite dataclass

A potential habitat location on a space body.

Resource fields are daily rates in their native units. Multiply by 30.44 (or use monthly=True in generate_site_report) to get monthly figures.

Attributes:

Name Type Description
id int

Numeric game-state ID.

display_name str

Site name as shown in-game.

template_name str

Internal template identifier (e.g. "MercurySite1"); falls back to display_name when absent.

body_id int

ID of the SpaceBody this site belongs to.

latitude float

Latitude of the site on its body, in degrees.

longitude float

Longitude of the site on its body, in degrees.

water_day float

Daily water extraction rate.

volatiles_day float

Daily volatiles extraction rate.

metals_day float

Daily metals extraction rate.

nobles_day float

Daily noble-gas extraction rate.

fissiles_day float

Daily fissiles extraction rate.

base_id Optional[int]

ID of the occupying Base, or None if the site is unoccupied.

pending_hab bool

True if a base is queued to be built here but does not yet exist.

Source code in src/tiargus/habs.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
@dataclass
class HabSite:
    """A potential habitat location on a space body.

    Resource fields are **daily** rates in their native units.
    Multiply by `30.44` (or use `monthly=True` in
    [generate_site_report][tiargus.commands.site_report.generate_site_report]) to get
    monthly figures.

    Attributes:
        id: Numeric game-state ID.
        display_name: Site name as shown in-game.
        template_name: Internal template identifier (e.g. ``"MercurySite1"``);
            falls back to ``display_name`` when absent.
        body_id: ID of the [SpaceBody][tiargus.solar_system.SpaceBody] this site belongs to.
        latitude: Latitude of the site on its body, in degrees.
        longitude: Longitude of the site on its body, in degrees.
        water_day: Daily water extraction rate.
        volatiles_day: Daily volatiles extraction rate.
        metals_day: Daily metals extraction rate.
        nobles_day: Daily noble-gas extraction rate.
        fissiles_day: Daily fissiles extraction rate.
        base_id: ID of the occupying [Base][tiargus.habs.Base], or `None`
            if the site is unoccupied.
        pending_hab: ``True`` if a base is queued to be built here but does not
            yet exist.
    """

    id: int
    display_name: str
    template_name: str
    body_id: int
    latitude: float
    longitude: float
    water_day: float
    volatiles_day: float
    metals_day: float
    nobles_day: float
    fissiles_day: float
    base_id: Optional[int]
    pending_hab: bool

HabSiteModule

Bases: SavegameModule

Parses all hab sites with their daily resource income and settlement references.

Depends on SpaceBodyModule, BaseModule, and FactionModule. After parsing, uses the player faction's prospected body IDs to populate prospected_hab_sites.

Source code in src/tiargus/habs.py
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
class HabSiteModule(SavegameModule):
    """Parses all hab sites with their daily resource income and settlement references.

    Depends on [SpaceBodyModule][tiargus.habs.SpaceBodyModule],
    [BaseModule][tiargus.habs.BaseModule], and
    [FactionModule][tiargus.factions.FactionModule].  After parsing, uses the player
    faction's prospected body IDs to populate
    [prospected_hab_sites][tiargus.habs.HabSiteModule.prospected_hab_sites].
    """

    KEY = "hab_sites"

    def __init__(self) -> None:
        self._hab_sites: dict[int, HabSite] = {}
        self._prospected_body_ids: set[int] = set()

    @property
    def hab_sites(self) -> dict[int, HabSite]:
        """All hab sites in the save, keyed by their numeric game-state ID."""
        return self._hab_sites

    @property
    def prospected_hab_sites(self) -> list[HabSite]:
        """Hab sites on bodies the player faction has fully prospected."""
        return [
            s
            for s in self._hab_sites.values()
            if s.body_id in self._prospected_body_ids
        ]

    @classmethod
    def requires(cls) -> list[str]:
        return [SpaceBodyModule.KEY, BaseModule.KEY, FactionModule.KEY]

    def parse(self, gs: dict, modules: dict[str, SavegameModule]) -> None:
        for item in gs[GS_HAB_SITE]:
            sid = item["Key"]["value"]
            site = item["Value"]
            hab_ref = site.get("hab")
            base_id: Optional[int] = None
            if isinstance(hab_ref, dict) and hab_ref.get("value") is not None:
                base_id = hab_ref["value"]
            self._hab_sites[sid] = HabSite(
                id=sid,
                display_name=site["displayName"],
                template_name=site.get("templateName") or site["displayName"],
                body_id=site["parentBody"]["value"],
                latitude=float(site.get("latitude", 0.0)),
                longitude=float(site.get("longitude", 0.0)),
                water_day=float(site.get("water_day", 0.0)),
                volatiles_day=float(site.get("volatiles_day", 0.0)),
                metals_day=float(site.get("metals_day", 0.0)),
                nobles_day=float(site.get("nobles_day", 0.0)),
                fissiles_day=float(site.get("fissiles_day", 0.0)),
                base_id=base_id,
                pending_hab=bool(site.get("pendingHab", False)),
            )

        faction_mod: FactionModule = modules[FactionModule.KEY]  # type: ignore[assignment]
        self._prospected_body_ids = faction_mod.player_faction.prospected_bodies

hab_sites property

All hab sites in the save, keyed by their numeric game-state ID.

prospected_hab_sites property

Hab sites on bodies the player faction has fully prospected.

__init_subclass__(**kwargs)

Register subclasses in _REGISTRY at definition time.

Raises:

Type Description
TypeError

If a subclass does not define KEY.

Source code in src/tiargus/savegame.py
141
142
143
144
145
146
147
148
149
150
def __init_subclass__(cls, **kwargs: object) -> None:
    """Register subclasses in ``_REGISTRY`` at definition time.

    Raises:
        TypeError: If a subclass does not define ``KEY``.
    """
    super().__init_subclass__(**kwargs)
    if not hasattr(cls, "KEY"):
        raise TypeError(f"{cls.__name__} must define KEY as a class variable")
    SavegameModule._REGISTRY[cls.KEY] = cls

SpaceBody dataclass

A planet, moon, or asteroid present in the save file.

Attributes:

Name Type Description
id int

Numeric game-state ID used as a foreign key by other entities.

display_name str

Human-readable name shown in-game (e.g. "Mars").

template_name str

Internal template identifier used to look up the region label in the bundled location map (e.g. "TIMars"). Falls back to display_name when absent.

max_hab_tier int

Maximum hab tier (1–3) that can be built on this body.

barycenter_id Optional[int]

ID of the body this one orbits (its barycenter), or None for Sol, which orbits nothing.

Source code in src/tiargus/solar_system.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@dataclass
class SpaceBody:
    """A planet, moon, or asteroid present in the save file.

    Attributes:
        id: Numeric game-state ID used as a foreign key by other entities.
        display_name: Human-readable name shown in-game (e.g. ``"Mars"``).
        template_name: Internal template identifier used to look up the
            region label in the bundled location map (e.g.
            ``"TIMars"``).  Falls back to ``display_name`` when absent.
        max_hab_tier: Maximum hab tier (1–3) that can be built on this body.
        barycenter_id: ID of the body this one orbits (its barycenter), or
            ``None`` for Sol, which orbits nothing.
    """

    id: int
    display_name: str
    template_name: str
    max_hab_tier: int
    barycenter_id: Optional[int]

SpaceBodyModule

Bases: SavegameModule

Parses every planet, moon, and asteroid from the save.

Provides display names and template names used by other modules to build location labels and resolve body references.

Source code in src/tiargus/solar_system.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class SpaceBodyModule(SavegameModule):
    """Parses every planet, moon, and asteroid from the save.

    Provides display names and template names used by other modules to build
    location labels and resolve body references.
    """

    KEY = "space_bodies"

    def __init__(self) -> None:
        self._space_bodies: dict[int, SpaceBody] = {}

    @property
    def space_bodies(self) -> dict[int, SpaceBody]:
        """All space bodies keyed by their numeric game-state ID."""
        return self._space_bodies

    def parse(self, gs: dict, modules: dict[str, SavegameModule]) -> None:
        for item in gs[GS_SPACE_BODY]:
            bid = item["Key"]["value"]
            body = item["Value"]
            barycenter = body.get("barycenter")
            barycenter_id = (
                barycenter["value"] if isinstance(barycenter, dict) else None
            )
            self._space_bodies[bid] = SpaceBody(
                id=bid,
                display_name=body["displayName"],
                template_name=body.get("templateName") or body["displayName"],
                max_hab_tier=int(body.get("maxHabTier", 1)),
                barycenter_id=barycenter_id,
            )

space_bodies property

All space bodies keyed by their numeric game-state ID.

__init_subclass__(**kwargs)

Register subclasses in _REGISTRY at definition time.

Raises:

Type Description
TypeError

If a subclass does not define KEY.

Source code in src/tiargus/savegame.py
141
142
143
144
145
146
147
148
149
150
def __init_subclass__(cls, **kwargs: object) -> None:
    """Register subclasses in ``_REGISTRY`` at definition time.

    Raises:
        TypeError: If a subclass does not define ``KEY``.
    """
    super().__init_subclass__(**kwargs)
    if not hasattr(cls, "KEY"):
        raise TypeError(f"{cls.__name__} must define KEY as a class variable")
    SavegameModule._REGISTRY[cls.KEY] = cls

requires() classmethod

Return the KEYs of modules that must be parsed before this one.

Override this in subclasses that need data from other modules. TerraInvictaSave resolves and parses all declared dependencies (and their transitive dependencies) before calling parse() on this module.

Returns:

Type Description
list[str]

A list of KEY strings. The default implementation returns an

list[str]

empty list (no dependencies).

Source code in src/tiargus/savegame.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
@classmethod
def requires(cls) -> list[str]:
    """Return the KEYs of modules that must be parsed before this one.

    Override this in subclasses that need data from other modules.
    ``TerraInvictaSave`` resolves and parses all declared dependencies
    (and their transitive dependencies) before calling ``parse()`` on this
    module.

    Returns:
        A list of ``KEY`` strings.  The default implementation returns an
        empty list (no dependencies).
    """
    return []

sites_dataframe(save, *, monthly=True, all_sites=False)

Build a DataFrame of hab-site resource data from a parsed save.

Parameters:

Name Type Description Default
save TerraInvictaSave

A TerraInvictaSave constructed with at least HabSiteModule.

required
monthly bool

When True (default), resource values are multiplied by 30.44 to show monthly income. Pass False for daily values.

True
all_sites bool

When False (default), only sites on bodies the player has fully prospected are included. Pass True to include every site in the save.

False

Returns:

Type Description
DataFrame

A DataFrame with one row per hab site. Columns: Name, Site, Max Tier,

DataFrame

Location, Water, Vols, Metal, Nobles, Fissiles, Faction, Mine Tier.

DataFrame

Score and Most are not included; those are Excel-formula columns added by

DataFrame
Source code in src/tiargus/habs.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def sites_dataframe(
    save: TerraInvictaSave,
    *,
    monthly: bool = True,
    all_sites: bool = False,
) -> pd.DataFrame:
    """Build a DataFrame of hab-site resource data from a parsed save.

    Args:
        save: A [TerraInvictaSave][tiargus.savegame.TerraInvictaSave]
            constructed with at least
            [HabSiteModule][tiargus.habs.HabSiteModule].
        monthly: When ``True`` (default), resource values are multiplied by
            ``30.44`` to show monthly income.  Pass ``False`` for daily values.
        all_sites: When ``False`` (default), only sites on bodies the player
            has fully prospected are included.  Pass ``True`` to include every
            site in the save.

    Returns:
        A DataFrame with one row per hab site.  Columns: Name, Site, Max Tier,
        Location, Water, Vols, Metal, Nobles, Fissiles, Faction, Mine Tier.
        Score and Most are not included; those are Excel-formula columns added by
        [generate_site_report][tiargus.commands.site_report.generate_site_report].
    """
    multiplier = _DAYS_PER_MONTH if monthly else 1
    hab_mod = save.get_module(HabSiteModule)
    sites = (
        list(hab_mod.hab_sites.values()) if all_sites else hab_mod.prospected_hab_sites
    )
    space_bodies = save.get_module(SpaceBodyModule).space_bodies
    body_names = {b.id: b.display_name for b in space_bodies.values()}
    location_map = _load_location_map()
    faction_names = {
        f.id: f.display_name for f in save.get_module(FactionModule).factions.values()
    }
    faction_names_display = {k: v[:1].upper() + v[1:] for k, v in faction_names.items()}
    bases = save.get_module(BaseModule).bases

    rows = []
    for site in sites:
        body = space_bodies.get(site.body_id)
        location = (
            location_map.get(body.template_name, body_names.get(site.body_id, ""))
            if body
            else ""
        )
        max_tier = body.max_hab_tier if body else 1
        faction_name = ""
        mine_tier: Optional[int] = None
        if site.base_id is not None and site.base_id in bases:
            base = bases[site.base_id]
            faction_name = faction_names_display.get(base.faction_id, "")
            mine_tier = base.mine_tier
        rows.append(
            {
                "Name": site.display_name,
                "Site": body_names.get(site.body_id, ""),
                "Max Tier": max_tier,
                "Location": location,
                "Water": site.water_day * multiplier,
                "Vols": site.volatiles_day * multiplier,
                "Metal": site.metals_day * multiplier,
                "Nobles": site.nobles_day * multiplier,
                "Fissiles": site.fissiles_day * multiplier,
                "Faction": faction_name,
                "Mine Tier": mine_tier,
            }
        )
    return pd.DataFrame(rows, columns=_SITE_COLUMNS)