arkparser 0.6.0__tar.gz → 0.7.1__tar.gz
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.
- {arkparser-0.6.0 → arkparser-0.7.1}/PKG-INFO +13 -4
- {arkparser-0.6.0 → arkparser-0.7.1}/README.md +12 -3
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/__init__.py +1 -1
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/types.py +11 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/data_models.py +5 -1
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/export.py +122 -74
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/world_save.py +5 -7
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/game_objects/game_object.py +11 -1
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/PKG-INFO +13 -4
- {arkparser-0.6.0 → arkparser-0.7.1}/pyproject.toml +1 -1
- arkparser-0.7.1/tests/test_export.py +177 -0
- arkparser-0.6.0/tests/test_export.py +0 -92
- {arkparser-0.6.0 → arkparser-0.7.1}/LICENSE +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/binary_reader.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/exceptions.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/map_config.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/normalization.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/common/version_detection.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/base.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/cloud_inventory.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/profile.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/files/tribe.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/game_objects/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/game_objects/container.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/game_objects/location.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/base.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/byte_property.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/compound.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/primitives.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/properties/registry.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/base.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/colors.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/misc.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/property_list.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/registry.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser/structs/vectors.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/SOURCES.txt +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/dependency_links.txt +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/requires.txt +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/arkparser.egg-info/top_level.txt +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/setup.cfg +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_asa_header_position.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_asa_name_table.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_ase_cluster_drift.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_binary_reader.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_binary_reader_layouts.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_cloud_export.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_cloud_inventory.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_cryopod_export.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_current_stats.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_data_models.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_game_objects.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_golden_exports.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_lazy_properties.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_profile.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_review_fixes.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_tribe.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_v13_property_layouts.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_version_detection.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.1}/tests/test_world_save.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arkparser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files
|
|
5
5
|
Author: Vertyco
|
|
6
6
|
License-Expression: MIT
|
|
@@ -314,7 +314,7 @@ ARK keeps player data in **two distinct places**, and the parser builds `ASV_Pla
|
|
|
314
314
|
|
|
315
315
|
The export pipeline (`export_players`) loops over `save.profiles` (a list the caller assembles). For each entry:
|
|
316
316
|
|
|
317
|
-
- If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`).
|
|
317
|
+
- If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`). It joins `PlayerDataID == LinkedPlayerDataID` against the world save's player pawns, so when a live pawn exists `lat`/`lon`/`ccc` come from that pawn's `.location`; players with no live pawn (dead / logged out) keep `lat`/`lon`/`ccc` at zero. `inventory` is empty and pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent (the profile carries neither).
|
|
318
318
|
- Otherwise it's treated as a wrapped `(profile, objects)` pair pointing at an in-world `PlayerPawn` → `_player_from_object` runs: fills location, inventory, pawn-state flags, body/hair cosmetics, death timestamps, and `active` from `SavedLastTimeHadController`.
|
|
319
319
|
|
|
320
320
|
For the richest output, hand `export_players` **both**, assemble a wrapper for each player that carries the `Profile` (for offline identity) *and* the in-world pawn (when present) so the merged record gets identity + live state. The current validation script only passes `Profile` instances, which is why fields like `is_sleeping` / `body_colors` / `current_weapon` show up empty in the validation output even though the parser supports them.
|
|
@@ -336,7 +336,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
336
336
|
| `netAddress` | legacy (now populated) | Last client IP ARK persisted (`SavedNetworkAddress` in profile `MyData`). Legacy ASVExport reads the same field (ContentPlayer.cs:157 ASE / :341 ASA). `""` when the profile lacks it (e.g. never-played placeholders). ASA stores a clean IPv4/IPv6 string; some ASE saves store an engine-truncated value (e.g. `"[2001"`) reproduced verbatim, matching legacy. |
|
|
337
337
|
| `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
|
|
338
338
|
| `active` | legacy (now populated) | ISO 8601 datetime of last login, converted from profile `LastLoginTime` or in-world pawn `SavedLastTimeHadController`. Legacy schema reserved the field but the old C# exporter only filled it for in-world pawns; the parser fills it for profiles too. `null` when neither source is present. |
|
|
339
|
-
| `lat`, `lon`, `ccc` | legacy (now populated) | In-world position
|
|
339
|
+
| `lat`, `lon`, `ccc` | legacy (now populated) | In-world position. Built directly from a `PlayerPawn` GameObject when one is passed, and for profile-sourced records the parser joins `PlayerDataID == LinkedPlayerDataID` to the world save's player pawns and pulls the matched pawn's `.location`. Records whose player has no live pawn (dead / logged out / cluster-only) keep the legacy `0` / `"0 0 0"` placeholders since there is no world position to report. |
|
|
340
340
|
| `inventory` | legacy (now populated) | Items from the pawn's `MyInventoryComponent` when built from an in-world pawn. Empty list otherwise. |
|
|
341
341
|
| `engram_points` | added | `TotalEngramPoints` |
|
|
342
342
|
| `experience` | added | `ExperiencePoints` (status), integer |
|
|
@@ -365,7 +365,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
365
365
|
| `members` | legacy | list of `{ign, lvl, playerid, playername, steamid}` built from the allocated players. `lvl` and `steamid` populate from the matching `.arkprofile`; members with no profile (back-filled from the tribe's member list) carry `lvl=0`, `steamid=""`. |
|
|
366
366
|
| `tames`, `structures` | legacy | counts derived from `WorldSave` (creatures + structures whose `TargetingTeam` matches) |
|
|
367
367
|
| `uploadedTames` | legacy | reserved (currently `0`) |
|
|
368
|
-
| `active` | legacy | ISO 8601 datetime of the
|
|
368
|
+
| `active` | legacy | ISO 8601 datetime, legacy `ContentTribe.LastActive`: max of the tribe file's write time (`.arktribe` mtime; world-save mtime for in-save tribes) and the allocated members' last-active times (`LastLoginTime` via the save anchor), discarding future values. `null` when no past candidate exists. Tribe-log "Day N" stamps are game-calendar time and are deliberately not used. |
|
|
369
369
|
| `dataFile` | legacy | `"{tribeid}.arktribe"` filename pattern. |
|
|
370
370
|
| `owner_id` | added | `OwnerPlayerDataID` / parser `Tribe.owner_player_id` |
|
|
371
371
|
| `owner_name` | added | `OwnerPlayerName` (object form only; parser-tribe form has no equivalent) |
|
|
@@ -681,6 +681,15 @@ Schema policy:
|
|
|
681
681
|
|
|
682
682
|
ASA worldsave property layouts differ between **v13** (`TheIsland_WP` and older single-player saves, legacy `AsaSavegameToolkit`-style `dataSize + position + typeRef + byte` body) and **v14+** (current production ASA, marker-based body). Both are parsed by version-aware property readers; `WorldSave.version` is the source of truth.
|
|
683
683
|
|
|
684
|
+
### Parse diagnostics (logging)
|
|
685
|
+
|
|
686
|
+
The parser logs through the standard `logging` module on the `arkparser.*` logger hierarchy. At DEBUG level it reports recoverable parse anomalies, for example ASE objects whose property block terminates early (the run of successfully parsed properties is kept and the remaining bytes are preserved on `GameObject.extra_data`):
|
|
687
|
+
|
|
688
|
+
```python
|
|
689
|
+
import logging
|
|
690
|
+
logging.getLogger("arkparser").setLevel(logging.DEBUG)
|
|
691
|
+
```
|
|
692
|
+
|
|
684
693
|
### Known limitation: ASA cryopod property blocks are partially decoded
|
|
685
694
|
|
|
686
695
|
ASA cryopods (`PrimalItem_WeaponEmptyCryopod_C`, `SoulTrap`, `Vivarium`, `DinoBall`) store the embedded creature snapshot inside the item's `CustomItemDatas.CustomDataBytes.ByteArrays[0]` as a **zlib + custom-RLE compressed `AsaDataStore` blob**. The parser surfaces the snapshot via the simpler `CustomDataStrings` / `CustomDataFloats` accessors, exposing **species, tamed name, level, color regions, and `CurrentStatusValues[0..11]`** on each cryopodded creature.
|
|
@@ -280,7 +280,7 @@ ARK keeps player data in **two distinct places**, and the parser builds `ASV_Pla
|
|
|
280
280
|
|
|
281
281
|
The export pipeline (`export_players`) loops over `save.profiles` (a list the caller assembles). For each entry:
|
|
282
282
|
|
|
283
|
-
- If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`).
|
|
283
|
+
- If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`). It joins `PlayerDataID == LinkedPlayerDataID` against the world save's player pawns, so when a live pawn exists `lat`/`lon`/`ccc` come from that pawn's `.location`; players with no live pawn (dead / logged out) keep `lat`/`lon`/`ccc` at zero. `inventory` is empty and pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent (the profile carries neither).
|
|
284
284
|
- Otherwise it's treated as a wrapped `(profile, objects)` pair pointing at an in-world `PlayerPawn` → `_player_from_object` runs: fills location, inventory, pawn-state flags, body/hair cosmetics, death timestamps, and `active` from `SavedLastTimeHadController`.
|
|
285
285
|
|
|
286
286
|
For the richest output, hand `export_players` **both**, assemble a wrapper for each player that carries the `Profile` (for offline identity) *and* the in-world pawn (when present) so the merged record gets identity + live state. The current validation script only passes `Profile` instances, which is why fields like `is_sleeping` / `body_colors` / `current_weapon` show up empty in the validation output even though the parser supports them.
|
|
@@ -302,7 +302,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
302
302
|
| `netAddress` | legacy (now populated) | Last client IP ARK persisted (`SavedNetworkAddress` in profile `MyData`). Legacy ASVExport reads the same field (ContentPlayer.cs:157 ASE / :341 ASA). `""` when the profile lacks it (e.g. never-played placeholders). ASA stores a clean IPv4/IPv6 string; some ASE saves store an engine-truncated value (e.g. `"[2001"`) reproduced verbatim, matching legacy. |
|
|
303
303
|
| `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
|
|
304
304
|
| `active` | legacy (now populated) | ISO 8601 datetime of last login, converted from profile `LastLoginTime` or in-world pawn `SavedLastTimeHadController`. Legacy schema reserved the field but the old C# exporter only filled it for in-world pawns; the parser fills it for profiles too. `null` when neither source is present. |
|
|
305
|
-
| `lat`, `lon`, `ccc` | legacy (now populated) | In-world position
|
|
305
|
+
| `lat`, `lon`, `ccc` | legacy (now populated) | In-world position. Built directly from a `PlayerPawn` GameObject when one is passed, and for profile-sourced records the parser joins `PlayerDataID == LinkedPlayerDataID` to the world save's player pawns and pulls the matched pawn's `.location`. Records whose player has no live pawn (dead / logged out / cluster-only) keep the legacy `0` / `"0 0 0"` placeholders since there is no world position to report. |
|
|
306
306
|
| `inventory` | legacy (now populated) | Items from the pawn's `MyInventoryComponent` when built from an in-world pawn. Empty list otherwise. |
|
|
307
307
|
| `engram_points` | added | `TotalEngramPoints` |
|
|
308
308
|
| `experience` | added | `ExperiencePoints` (status), integer |
|
|
@@ -331,7 +331,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
331
331
|
| `members` | legacy | list of `{ign, lvl, playerid, playername, steamid}` built from the allocated players. `lvl` and `steamid` populate from the matching `.arkprofile`; members with no profile (back-filled from the tribe's member list) carry `lvl=0`, `steamid=""`. |
|
|
332
332
|
| `tames`, `structures` | legacy | counts derived from `WorldSave` (creatures + structures whose `TargetingTeam` matches) |
|
|
333
333
|
| `uploadedTames` | legacy | reserved (currently `0`) |
|
|
334
|
-
| `active` | legacy | ISO 8601 datetime of the
|
|
334
|
+
| `active` | legacy | ISO 8601 datetime, legacy `ContentTribe.LastActive`: max of the tribe file's write time (`.arktribe` mtime; world-save mtime for in-save tribes) and the allocated members' last-active times (`LastLoginTime` via the save anchor), discarding future values. `null` when no past candidate exists. Tribe-log "Day N" stamps are game-calendar time and are deliberately not used. |
|
|
335
335
|
| `dataFile` | legacy | `"{tribeid}.arktribe"` filename pattern. |
|
|
336
336
|
| `owner_id` | added | `OwnerPlayerDataID` / parser `Tribe.owner_player_id` |
|
|
337
337
|
| `owner_name` | added | `OwnerPlayerName` (object form only; parser-tribe form has no equivalent) |
|
|
@@ -647,6 +647,15 @@ Schema policy:
|
|
|
647
647
|
|
|
648
648
|
ASA worldsave property layouts differ between **v13** (`TheIsland_WP` and older single-player saves, legacy `AsaSavegameToolkit`-style `dataSize + position + typeRef + byte` body) and **v14+** (current production ASA, marker-based body). Both are parsed by version-aware property readers; `WorldSave.version` is the source of truth.
|
|
649
649
|
|
|
650
|
+
### Parse diagnostics (logging)
|
|
651
|
+
|
|
652
|
+
The parser logs through the standard `logging` module on the `arkparser.*` logger hierarchy. At DEBUG level it reports recoverable parse anomalies, for example ASE objects whose property block terminates early (the run of successfully parsed properties is kept and the remaining bytes are preserved on `GameObject.extra_data`):
|
|
653
|
+
|
|
654
|
+
```python
|
|
655
|
+
import logging
|
|
656
|
+
logging.getLogger("arkparser").setLevel(logging.DEBUG)
|
|
657
|
+
```
|
|
658
|
+
|
|
650
659
|
### Known limitation: ASA cryopod property blocks are partially decoded
|
|
651
660
|
|
|
652
661
|
ASA cryopods (`PrimalItem_WeaponEmptyCryopod_C`, `SoulTrap`, `Vivarium`, `DinoBall`) store the embedded creature snapshot inside the item's `CustomItemDatas.CustomDataBytes.ByteArrays[0]` as a **zlib + custom-RLE compressed `AsaDataStore` blob**. The parser surfaces the snapshot via the simpler `CustomDataStrings` / `CustomDataFloats` accessors, exposing **species, tamed name, level, color regions, and `CurrentStatusValues[0..11]`** on each cryopodded creature.
|
|
@@ -21,6 +21,17 @@ from dataclasses import dataclass
|
|
|
21
21
|
# The "None" name is used as a terminator in property lists
|
|
22
22
|
NAME_NONE = "None"
|
|
23
23
|
|
|
24
|
+
# Class-name / blueprint fragments that mark a creature-storage item.
|
|
25
|
+
# Single source of truth for cryopod detection: in-world iteration
|
|
26
|
+
# (WorldSave.iter_cryopod_creatures), inventory filtering (export), and
|
|
27
|
+
# uploaded-item checks (UploadedItem.is_cryopod) all match against this.
|
|
28
|
+
CRYOPOD_CLASS_PATTERNS: tuple[str, ...] = (
|
|
29
|
+
"Cryopod",
|
|
30
|
+
"SoulTrap",
|
|
31
|
+
"Vivarium",
|
|
32
|
+
"DinoBall",
|
|
33
|
+
)
|
|
34
|
+
|
|
24
35
|
|
|
25
36
|
# =============================================================================
|
|
26
37
|
# ArkName (FName)
|
|
@@ -15,10 +15,14 @@ from dataclasses import dataclass, field
|
|
|
15
15
|
|
|
16
16
|
from .common.binary_reader import BinaryReader
|
|
17
17
|
from .common.normalization import normalize_indexed_data, normalize_indexed_list
|
|
18
|
+
from .common.types import CRYOPOD_CLASS_PATTERNS
|
|
18
19
|
from .properties.registry import read_properties
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
21
22
|
|
|
23
|
+
# Uploaded items match against lowercased blueprint paths.
|
|
24
|
+
_CRYOPOD_PATTERNS_LOWER: tuple[str, ...] = tuple(p.lower() for p in CRYOPOD_CLASS_PATTERNS)
|
|
25
|
+
|
|
22
26
|
# Trailing UE actor spawn-instance suffix on cryopod class names, e.g.
|
|
23
27
|
# "Raptor_Character_BP_C_2145673735". ARK stores the full instance name in the
|
|
24
28
|
# cryopod CustomDataStrings/SoftClasses blob; the worldsave name-table path
|
|
@@ -820,7 +824,7 @@ class UploadedItem:
|
|
|
820
824
|
def is_cryopod(self) -> bool:
|
|
821
825
|
"""Check if this item is a cryopod (or similar creature storage item)."""
|
|
822
826
|
bp_lower = self.blueprint.lower()
|
|
823
|
-
return any(x in bp_lower for x in
|
|
827
|
+
return any(x in bp_lower for x in _CRYOPOD_PATTERNS_LOWER)
|
|
824
828
|
|
|
825
829
|
@property
|
|
826
830
|
def cryopod_creature(self) -> CryopodCreature | None:
|
|
@@ -44,21 +44,14 @@ from pathlib import Path
|
|
|
44
44
|
from arkparser.common.exceptions import ArkParseError
|
|
45
45
|
from arkparser.common.map_config import MapConfig
|
|
46
46
|
from arkparser.common.normalization import normalize_indexed_data, normalize_indexed_list
|
|
47
|
+
from arkparser.common.types import CRYOPOD_CLASS_PATTERNS
|
|
47
48
|
from arkparser.data_models import CryopodCreature
|
|
48
49
|
from arkparser.files import CloudInventory, Profile, Tribe
|
|
49
50
|
|
|
50
51
|
logger = logging.getLogger(__name__)
|
|
51
52
|
|
|
52
|
-
_CRYOPOD_CLASS_PATTERNS: tuple[str, ...] = (
|
|
53
|
-
"Cryopod",
|
|
54
|
-
"SoulTrap",
|
|
55
|
-
"Vivarium",
|
|
56
|
-
"DinoBall",
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
|
|
60
53
|
def _is_cryopod_class(class_name: str) -> bool:
|
|
61
|
-
return any(p in class_name for p in
|
|
54
|
+
return any(p in class_name for p in CRYOPOD_CLASS_PATTERNS)
|
|
62
55
|
|
|
63
56
|
|
|
64
57
|
def _decode_inventory_cryopod(item_obj: t.Any) -> CryopodCreature | None:
|
|
@@ -152,6 +145,10 @@ _LEGACY_STAT_ORDER: tuple[str, ...] = (
|
|
|
152
145
|
)
|
|
153
146
|
_EXTRA_STAT_ORDER: tuple[str, ...] = ("torp", "water", "temp", "fort")
|
|
154
147
|
_FLAT_STAT_ORDER: tuple[str, ...] = _LEGACY_STAT_ORDER + _EXTRA_STAT_ORDER
|
|
148
|
+
# The order tuples are hand-maintained views of _STAT_NAMES; catch drift at
|
|
149
|
+
# import time before it silently misaligns exported stat JSON.
|
|
150
|
+
assert len(_FLAT_STAT_ORDER) == len(_STAT_NAMES), "stat order tuples out of sync"
|
|
151
|
+
assert set(_FLAT_STAT_ORDER) == set(_STAT_NAMES), "stat order tuples out of sync"
|
|
155
152
|
|
|
156
153
|
|
|
157
154
|
def _prop(obj: t.Any, name: str, default: t.Any = None, index: int | None = None) -> t.Any:
|
|
@@ -2058,6 +2055,8 @@ def _player_from_profile(
|
|
|
2058
2055
|
profile: Profile,
|
|
2059
2056
|
save: t.Any = None,
|
|
2060
2057
|
pawn_status_by_id: dict[int, t.Any] | None = None,
|
|
2058
|
+
pawn_by_data_id: dict[int, t.Any] | None = None,
|
|
2059
|
+
map_config: MapConfig | None = None,
|
|
2061
2060
|
) -> dict[str, t.Any]:
|
|
2062
2061
|
stat_points = [_int(profile.get_stat(i)["added"]) for i in range(12)]
|
|
2063
2062
|
gamertag = profile.player_name or ""
|
|
@@ -2069,8 +2068,18 @@ def _player_from_profile(
|
|
|
2069
2068
|
# not in the profile. Resolve via PlayerDataID -> pawn -> status. Absent
|
|
2070
2069
|
# when the player has no spawned body (never spawned / corpse cleared).
|
|
2071
2070
|
status = None
|
|
2072
|
-
|
|
2073
|
-
|
|
2071
|
+
pawn = None
|
|
2072
|
+
if profile.player_id:
|
|
2073
|
+
pid = int(profile.player_id)
|
|
2074
|
+
if pawn_status_by_id:
|
|
2075
|
+
status = pawn_status_by_id.get(pid)
|
|
2076
|
+
if pawn_by_data_id:
|
|
2077
|
+
pawn = pawn_by_data_id.get(pid)
|
|
2078
|
+
|
|
2079
|
+
# The profile carries no world position. When the player has a live pawn in
|
|
2080
|
+
# the save, pull lat/lon/ccc from it; otherwise keep the legacy 0/0 zeroes
|
|
2081
|
+
# (dead / logged-out players have no location to report).
|
|
2082
|
+
gps = _gps_payload(pawn, map_config, ndigits=2) if pawn is not None else {"ccc": "0 0 0", "lat": 0.0, "lon": 0.0}
|
|
2074
2083
|
|
|
2075
2084
|
out: dict[str, t.Any] = {
|
|
2076
2085
|
"playerid": profile.player_id or 0,
|
|
@@ -2080,11 +2089,11 @@ def _player_from_profile(
|
|
|
2080
2089
|
"tribe": profile.tribe_name or "",
|
|
2081
2090
|
"sex": "Female" if profile.is_female is True else "Male",
|
|
2082
2091
|
"lvl": profile.level,
|
|
2083
|
-
"lat":
|
|
2084
|
-
"lon":
|
|
2092
|
+
"lat": gps["lat"],
|
|
2093
|
+
"lon": gps["lon"],
|
|
2085
2094
|
**_flat_stats(stat_points),
|
|
2086
2095
|
"active": active_dt.isoformat() if active_dt is not None else None,
|
|
2087
|
-
"ccc": "
|
|
2096
|
+
"ccc": gps["ccc"],
|
|
2088
2097
|
"achievements": [],
|
|
2089
2098
|
"inventory": [],
|
|
2090
2099
|
"netAddress": _str(profile.last_net_address),
|
|
@@ -2168,16 +2177,25 @@ def _player_from_object(
|
|
|
2168
2177
|
return _compact(data, LEGACY_PLAYER_KEYS)
|
|
2169
2178
|
|
|
2170
2179
|
|
|
2171
|
-
def _player_status_by_data_id(
|
|
2172
|
-
|
|
2180
|
+
def _player_status_by_data_id(
|
|
2181
|
+
save: t.Any,
|
|
2182
|
+
lookup: dict[t.Any, t.Any],
|
|
2183
|
+
) -> tuple[dict[int, t.Any], dict[int, t.Any]]:
|
|
2184
|
+
"""Index per-player pawn + status component by ``LinkedPlayerDataID``.
|
|
2173
2185
|
|
|
2174
2186
|
Walks every ``PlayerPawnTest_*_C`` / ``PlayerCharacter_*`` in the world
|
|
2175
2187
|
save, reads its ``LinkedPlayerDataID``, follows ``MyCharacterStatusComponent``
|
|
2176
|
-
via ``lookup``, and stores the resolved status object
|
|
2177
|
-
player exports surface live
|
|
2178
|
-
|
|
2188
|
+
via ``lookup``, and stores both the resolved status object and the pawn
|
|
2189
|
+
itself. The status index lets profile-based player exports surface live
|
|
2190
|
+
HP/stamina/food/etc.; the pawn index lets them surface live lat/lon/ccc
|
|
2191
|
+
(the pawn carries ``.location``, the profile does not), without legacy
|
|
2192
|
+
ASVPack having to do the same join.
|
|
2193
|
+
|
|
2194
|
+
Returns ``(status_by_id, pawn_by_id)``; a pawn appears in ``pawn_by_id``
|
|
2195
|
+
even when its status component fails to resolve.
|
|
2179
2196
|
"""
|
|
2180
|
-
|
|
2197
|
+
status_out: dict[int, t.Any] = {}
|
|
2198
|
+
pawn_out: dict[int, t.Any] = {}
|
|
2181
2199
|
objects = getattr(save, "objects", None) or []
|
|
2182
2200
|
for obj in objects:
|
|
2183
2201
|
cn = str(getattr(obj, "class_name", "") or "")
|
|
@@ -2186,10 +2204,11 @@ def _player_status_by_data_id(save: t.Any, lookup: dict[t.Any, t.Any]) -> dict[i
|
|
|
2186
2204
|
pid = _int(_prop(obj, "LinkedPlayerDataID"))
|
|
2187
2205
|
if not pid:
|
|
2188
2206
|
continue
|
|
2207
|
+
pawn_out[pid] = obj
|
|
2189
2208
|
status = _status_for(obj, lookup)
|
|
2190
2209
|
if status is not None:
|
|
2191
|
-
|
|
2192
|
-
return
|
|
2210
|
+
status_out[pid] = status
|
|
2211
|
+
return status_out, pawn_out
|
|
2193
2212
|
|
|
2194
2213
|
|
|
2195
2214
|
def _cluster_items_by_xuid(
|
|
@@ -2226,6 +2245,7 @@ def _player_record_for(
|
|
|
2226
2245
|
entry: t.Any,
|
|
2227
2246
|
save: t.Any,
|
|
2228
2247
|
pawn_status_by_id: dict[int, t.Any],
|
|
2248
|
+
pawn_by_data_id: dict[int, t.Any],
|
|
2229
2249
|
lookup: dict[t.Any, t.Any],
|
|
2230
2250
|
map_config: MapConfig | None,
|
|
2231
2251
|
) -> tuple[dict[str, t.Any] | None, list[str]]:
|
|
@@ -2237,7 +2257,7 @@ def _player_record_for(
|
|
|
2237
2257
|
"""
|
|
2238
2258
|
join_keys: list[str] = []
|
|
2239
2259
|
if isinstance(entry, Profile):
|
|
2240
|
-
record = _player_from_profile(entry, save, pawn_status_by_id)
|
|
2260
|
+
record = _player_from_profile(entry, save, pawn_status_by_id, pawn_by_data_id, map_config)
|
|
2241
2261
|
if entry.source_path is not None and entry.source_path.stem:
|
|
2242
2262
|
join_keys.append(entry.source_path.stem)
|
|
2243
2263
|
if entry.unique_id:
|
|
@@ -2312,7 +2332,7 @@ def _iter_players(
|
|
|
2312
2332
|
"""Yield ASV_Players records one at a time (streaming form of export_players)."""
|
|
2313
2333
|
profiles = _collection(save, "profiles", Profile)
|
|
2314
2334
|
lookup = _save_lookup(save)
|
|
2315
|
-
pawn_status_by_id = _player_status_by_data_id(save, lookup)
|
|
2335
|
+
pawn_status_by_id, pawn_by_data_id = _player_status_by_data_id(save, lookup)
|
|
2316
2336
|
cluster_items = _cluster_items_by_xuid(cluster_inventories, save) if cluster_inventories else {}
|
|
2317
2337
|
# Legacy emits each player's tribeid as the CONTAINING tribe's id (the one
|
|
2318
2338
|
# whose member list / team claims them), not the profile's own field. The
|
|
@@ -2321,7 +2341,9 @@ def _iter_players(
|
|
|
2321
2341
|
profile_tribeid = allocation["profile_tribeid"]
|
|
2322
2342
|
tribe_names = allocation["names"]
|
|
2323
2343
|
for entry in profiles:
|
|
2324
|
-
record, join_keys = _player_record_for(
|
|
2344
|
+
record, join_keys = _player_record_for(
|
|
2345
|
+
entry, save, pawn_status_by_id, pawn_by_data_id, lookup, map_config
|
|
2346
|
+
)
|
|
2325
2347
|
if record is None:
|
|
2326
2348
|
continue
|
|
2327
2349
|
if isinstance(entry, Profile):
|
|
@@ -2388,45 +2410,67 @@ def _tribe_members_from_parser(
|
|
|
2388
2410
|
return out
|
|
2389
2411
|
|
|
2390
2412
|
|
|
2391
|
-
def
|
|
2392
|
-
"""
|
|
2393
|
-
|
|
2394
|
-
Tribe files don't carry an "active" timestamp directly. Each log entry
|
|
2395
|
-
is formatted ``Day N, HH:MM:SS: <message>`` (game-time). The most recent
|
|
2396
|
-
entry yields the highest game-second count, which combined with the
|
|
2397
|
-
save's anchor (``file_mtime + (max_log_seconds - game_time)``) gives a
|
|
2398
|
-
real wall-clock timestamp.
|
|
2413
|
+
def _tribe_file_date(tribe: t.Any, save: t.Any) -> dt.datetime | None:
|
|
2414
|
+
"""Wall-clock write time of a tribe's backing file (legacy TribeFileDate).
|
|
2399
2415
|
|
|
2400
|
-
|
|
2401
|
-
|
|
2416
|
+
File-backed tribes use the .arktribe mtime (ContentContainer.cs:1812);
|
|
2417
|
+
tribes parsed out of the world save fall back to the save's own mtime
|
|
2418
|
+
(ContentContainer.cs:734, GameSaveTime).
|
|
2402
2419
|
"""
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
if not isinstance(raw, str):
|
|
2406
|
-
continue
|
|
2407
|
-
m = _LOG_RE.match(raw.strip())
|
|
2408
|
-
if not m:
|
|
2409
|
-
continue
|
|
2420
|
+
src = getattr(tribe, "source_path", None) if tribe is not None else None
|
|
2421
|
+
if src is not None:
|
|
2410
2422
|
try:
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2423
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
2424
|
+
return dt.datetime.fromtimestamp(src.stat().st_mtime, tz=local_tz)
|
|
2425
|
+
except OSError:
|
|
2426
|
+
logger.debug("stat failed for tribe file %s", src)
|
|
2427
|
+
return getattr(save, "file_mtime", None) if save is not None else None
|
|
2428
|
+
|
|
2429
|
+
|
|
2430
|
+
def _tribe_active_iso(
|
|
2431
|
+
file_date: t.Any,
|
|
2432
|
+
member_ids: t.Iterable[int],
|
|
2433
|
+
profile_active: dict[int, dt.datetime],
|
|
2434
|
+
) -> str | None:
|
|
2435
|
+
"""Legacy ContentTribe.LastActive (ContentTribe.cs:35-49): the max of the
|
|
2436
|
+
tribe file date and the members' last-active datetimes, discarding values
|
|
2437
|
+
in the future. Tribe-log "Day N" stamps are game-calendar time, not real
|
|
2438
|
+
seconds, so they cannot be converted with the save anchor and are unused.
|
|
2439
|
+
|
|
2440
|
+
Returns ``None`` when no past candidate exists.
|
|
2441
|
+
"""
|
|
2442
|
+
assert isinstance(profile_active, dict), "profile_active must be a dict"
|
|
2443
|
+
candidates: list[dt.datetime] = []
|
|
2444
|
+
if isinstance(file_date, dt.datetime):
|
|
2445
|
+
candidates.append(file_date if file_date.tzinfo else file_date.astimezone())
|
|
2446
|
+
for i, pid in enumerate(member_ids):
|
|
2447
|
+
assert i < _MAX_TRIBE_MEMBERS, "tribe member list exceeded bound"
|
|
2448
|
+
active = profile_active.get(_int(pid))
|
|
2449
|
+
if active is not None:
|
|
2450
|
+
candidates.append(active if active.tzinfo else active.astimezone())
|
|
2451
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
2452
|
+
past = [c for c in candidates if c <= now]
|
|
2453
|
+
if not past:
|
|
2427
2454
|
return None
|
|
2428
|
-
|
|
2429
|
-
|
|
2455
|
+
return max(past).isoformat()
|
|
2456
|
+
|
|
2457
|
+
|
|
2458
|
+
def _profile_actives(profiles: list[Profile], save: t.Any) -> dict[int, dt.datetime]:
|
|
2459
|
+
"""``player_id -> last-active datetime`` for tribe activity rollups.
|
|
2460
|
+
|
|
2461
|
+
Mirrors legacy ContentPlayer.LastActiveDateTime: the profile's
|
|
2462
|
+
LastLoginTime converted through the save anchor.
|
|
2463
|
+
"""
|
|
2464
|
+
assert isinstance(profiles, list), "profiles must be a list"
|
|
2465
|
+
out: dict[int, dt.datetime] = {}
|
|
2466
|
+
for prof in profiles:
|
|
2467
|
+
pid = _int(prof.player_id)
|
|
2468
|
+
if not pid:
|
|
2469
|
+
continue
|
|
2470
|
+
active = _approx_real_datetime(prof.last_login_time, save)
|
|
2471
|
+
if active is not None:
|
|
2472
|
+
out[pid] = active
|
|
2473
|
+
return out
|
|
2430
2474
|
|
|
2431
2475
|
|
|
2432
2476
|
def _build_profile_index(save: t.Any) -> dict[int, Profile]:
|
|
@@ -2455,7 +2499,7 @@ def _tribe_from_parser(
|
|
|
2455
2499
|
"tames": c.get("tames", 0),
|
|
2456
2500
|
"uploadedTames": 0,
|
|
2457
2501
|
"structures": c.get("structures", 0),
|
|
2458
|
-
"active":
|
|
2502
|
+
"active": _tribe_file_date(tribe, save),
|
|
2459
2503
|
"dataFile": f"{tid}.arktribe" if tid else "",
|
|
2460
2504
|
"owner_id": tribe.owner_player_id or 0,
|
|
2461
2505
|
"alliance_ids": tribe.alliance_ids,
|
|
@@ -2472,8 +2516,7 @@ def _tribe_from_object(
|
|
|
2472
2516
|
tribe_id = _int(_prop(obj, "TribeID")) or _int(_prop(obj, "TribeId"))
|
|
2473
2517
|
owner_id = _int(_prop(obj, "OwnerPlayerDataID")) or _int(_prop(obj, "OwnerPlayerDataId"))
|
|
2474
2518
|
members: list[dict[str, t.Any]] = []
|
|
2475
|
-
i
|
|
2476
|
-
while True:
|
|
2519
|
+
for i in range(_MAX_TRIBE_MEMBERS):
|
|
2477
2520
|
pid = _prop(obj, "MembersPlayerDataID", index=i)
|
|
2478
2521
|
if pid is None:
|
|
2479
2522
|
break
|
|
@@ -2489,15 +2532,14 @@ def _tribe_from_object(
|
|
|
2489
2532
|
"steamid": (profile.unique_id or "") if profile is not None else "",
|
|
2490
2533
|
}
|
|
2491
2534
|
)
|
|
2492
|
-
|
|
2535
|
+
assert len(members) < _MAX_TRIBE_MEMBERS, "tribe member list exceeded bound"
|
|
2493
2536
|
alliances: list[int] = []
|
|
2494
|
-
j
|
|
2495
|
-
while True:
|
|
2537
|
+
for j in range(_MAX_TRIBE_MEMBERS):
|
|
2496
2538
|
val = _prop(obj, "TribeAlliances", index=j)
|
|
2497
2539
|
if val is None:
|
|
2498
2540
|
break
|
|
2499
2541
|
alliances.append(_int(val))
|
|
2500
|
-
|
|
2542
|
+
assert len(alliances) < _MAX_TRIBE_MEMBERS, "tribe alliance list exceeded bound"
|
|
2501
2543
|
c = counts.get(tribe_id, {})
|
|
2502
2544
|
data: dict[str, t.Any] = {
|
|
2503
2545
|
"tribeid": tribe_id,
|
|
@@ -2507,7 +2549,7 @@ def _tribe_from_object(
|
|
|
2507
2549
|
"tames": c.get("tames", 0),
|
|
2508
2550
|
"uploadedTames": 0,
|
|
2509
2551
|
"structures": c.get("structures", 0),
|
|
2510
|
-
"active":
|
|
2552
|
+
"active": _tribe_file_date(None, save),
|
|
2511
2553
|
"dataFile": f"{tribe_id}.arktribe" if tribe_id else "",
|
|
2512
2554
|
"owner_id": owner_id,
|
|
2513
2555
|
"owner_name": _str(_prop(obj, "OwnerPlayerName")),
|
|
@@ -2522,14 +2564,15 @@ def _tribe_object_logs(obj: t.Any) -> list[str]:
|
|
|
2522
2564
|
if isinstance(log_val, list):
|
|
2523
2565
|
return [e for e in log_val if isinstance(e, str) and e.strip()]
|
|
2524
2566
|
out: list[str] = []
|
|
2525
|
-
|
|
2526
|
-
|
|
2567
|
+
entries = 0
|
|
2568
|
+
for k in range(_MAX_TRIBE_MEMBERS):
|
|
2527
2569
|
v = _prop(obj, "TribeLog", index=k)
|
|
2528
2570
|
if v is None:
|
|
2529
2571
|
break
|
|
2572
|
+
entries += 1
|
|
2530
2573
|
if isinstance(v, str) and v.strip():
|
|
2531
2574
|
out.append(v)
|
|
2532
|
-
|
|
2575
|
+
assert entries < _MAX_TRIBE_MEMBERS, "tribe log list exceeded bound"
|
|
2533
2576
|
return out
|
|
2534
2577
|
|
|
2535
2578
|
|
|
@@ -2725,6 +2768,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2725
2768
|
member_name.setdefault(pid, mname)
|
|
2726
2769
|
file_tribe_ids = set(rich) | {_UNCLAIMED_TRIBE_ID, _ABANDONED_TRIBE_ID}
|
|
2727
2770
|
profiles = _profile_entries(save)
|
|
2771
|
+
profile_active = _profile_actives(profiles, save)
|
|
2728
2772
|
profile_tribeid, players_by_tribe = _allocate_profiles(profiles, file_tribe_ids, member_index)
|
|
2729
2773
|
for prof in profiles:
|
|
2730
2774
|
pid = _int(prof.player_id)
|
|
@@ -2750,6 +2794,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2750
2794
|
"logs": logs,
|
|
2751
2795
|
"counts": counts,
|
|
2752
2796
|
"profile_index": profile_index,
|
|
2797
|
+
"profile_active": profile_active,
|
|
2753
2798
|
"players_by_tribe": players_by_tribe,
|
|
2754
2799
|
"profile_tribeid": profile_tribeid,
|
|
2755
2800
|
"member_stubs": member_stubs,
|
|
@@ -2796,10 +2841,12 @@ def _members_for(tid: int, a: dict[str, t.Any]) -> list[dict[str, t.Any]]:
|
|
|
2796
2841
|
def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
2797
2842
|
"""Final ASV_Tribes record for one assembled tribe id.
|
|
2798
2843
|
|
|
2799
|
-
File-backed tribes reuse the rich record (
|
|
2800
|
-
|
|
2844
|
+
File-backed tribes reuse the rich record (dataFile/owner/alliances) but
|
|
2845
|
+
have ``players``/``members`` overridden to the allocated-player set so
|
|
2801
2846
|
they match the legacy writer (which counts allocated profiles, not the raw
|
|
2802
2847
|
member list). Synthesized stubs carry id + name + world-derived counts.
|
|
2848
|
+
``active`` finalizes here for both shapes: max of the tribe file date and
|
|
2849
|
+
the allocated members' last-active times (legacy ContentTribe.LastActive).
|
|
2803
2850
|
"""
|
|
2804
2851
|
players = a["players_by_tribe"].get(tid, [])
|
|
2805
2852
|
base = a["rich"].get(tid)
|
|
@@ -2807,6 +2854,7 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
|
2807
2854
|
rec = dict(base)
|
|
2808
2855
|
rec["players"] = len(players)
|
|
2809
2856
|
rec["members"] = _members_for(tid, a)
|
|
2857
|
+
rec["active"] = _tribe_active_iso(rec.get("active"), players, a["profile_active"])
|
|
2810
2858
|
return rec
|
|
2811
2859
|
c = a["counts"].get(tid, {})
|
|
2812
2860
|
data: dict[str, t.Any] = {
|
|
@@ -2817,7 +2865,7 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
|
2817
2865
|
"tames": c.get("tames", 0),
|
|
2818
2866
|
"uploadedTames": 0,
|
|
2819
2867
|
"structures": c.get("structures", 0),
|
|
2820
|
-
"active": None,
|
|
2868
|
+
"active": _tribe_active_iso(None, players, a["profile_active"]),
|
|
2821
2869
|
"dataFile": "",
|
|
2822
2870
|
}
|
|
2823
2871
|
return _compact(data, LEGACY_TRIBE_KEYS)
|
|
@@ -34,6 +34,7 @@ from uuid import UUID
|
|
|
34
34
|
from ..common.binary_reader import BinaryReader, guid_str_le
|
|
35
35
|
from ..common.exceptions import ArkParseError, CorruptDataError
|
|
36
36
|
from ..common.normalization import normalize_indexed_data, normalize_indexed_list
|
|
37
|
+
from ..common.types import CRYOPOD_CLASS_PATTERNS
|
|
37
38
|
from ..data_models import CryopodCreature
|
|
38
39
|
from ..game_objects.container import GameObjectContainer
|
|
39
40
|
from ..game_objects.game_object import MAX_OBJECT_COUNT, GameObject
|
|
@@ -428,13 +429,10 @@ class WorldSave:
|
|
|
428
429
|
"""Return creature nest objects (wyvern, drake, etc.)."""
|
|
429
430
|
return self.container.get_nests()
|
|
430
431
|
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
#
|
|
434
|
-
|
|
435
|
-
_CRYOPOD_PATTERNS: t.ClassVar[tuple[str, ...]] = (
|
|
436
|
-
"Cryopod", "SoulTrap", "Vivarium", "DinoBall",
|
|
437
|
-
)
|
|
432
|
+
# Shared with data_models.UploadedItem.is_cryopod (blueprint paths) and
|
|
433
|
+
# the export inventory filter; the in-world version matches against the
|
|
434
|
+
# GameObject's ``class_name`` directly.
|
|
435
|
+
_CRYOPOD_PATTERNS: t.ClassVar[tuple[str, ...]] = CRYOPOD_CLASS_PATTERNS
|
|
438
436
|
|
|
439
437
|
def iter_cryopod_creatures(self) -> t.Iterator[tuple[GameObject, CryopodCreature]]:
|
|
440
438
|
"""Yield ``(item_obj, CryopodCreature)`` for every *filled* cryopod
|
|
@@ -7,6 +7,7 @@ Game objects represent creatures, items, structures, players, and other entities
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import logging
|
|
10
11
|
import re
|
|
11
12
|
import typing as t
|
|
12
13
|
from collections import defaultdict
|
|
@@ -16,6 +17,8 @@ from ..common.exceptions import CorruptDataError
|
|
|
16
17
|
from ..properties.registry import read_properties, read_property
|
|
17
18
|
from .location import LocationData
|
|
18
19
|
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
19
22
|
if t.TYPE_CHECKING:
|
|
20
23
|
from ..common.binary_reader import BinaryReader
|
|
21
24
|
from ..properties.base import Property
|
|
@@ -420,11 +423,18 @@ class GameObject:
|
|
|
420
423
|
if prop is None:
|
|
421
424
|
break
|
|
422
425
|
properties.append(prop)
|
|
423
|
-
except Exception:
|
|
426
|
+
except Exception as exc:
|
|
424
427
|
self.properties = properties
|
|
425
428
|
self._props_loaded = True
|
|
426
429
|
if next_object is None:
|
|
427
430
|
raise
|
|
431
|
+
logger.debug(
|
|
432
|
+
"property parse stopped early for %s after %d properties, "
|
|
433
|
+
"remainder kept as extra_data: %s",
|
|
434
|
+
self.class_name,
|
|
435
|
+
len(properties),
|
|
436
|
+
exc,
|
|
437
|
+
)
|
|
428
438
|
|
|
429
439
|
next_offset = properties_block_offset + next_object.properties_offset
|
|
430
440
|
remaining = next_offset - reader.position
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arkparser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files
|
|
5
5
|
Author: Vertyco
|
|
6
6
|
License-Expression: MIT
|
|
@@ -314,7 +314,7 @@ ARK keeps player data in **two distinct places**, and the parser builds `ASV_Pla
|
|
|
314
314
|
|
|
315
315
|
The export pipeline (`export_players`) loops over `save.profiles` (a list the caller assembles). For each entry:
|
|
316
316
|
|
|
317
|
-
- If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`).
|
|
317
|
+
- If it's a `Profile` instance → `_player_from_profile` runs: fills core identity (name, gender, level, stats, tribe id, engram count, experience, active datetime from `LastLoginTime`). It joins `PlayerDataID == LinkedPlayerDataID` against the world save's player pawns, so when a live pawn exists `lat`/`lon`/`ccc` come from that pawn's `.location`; players with no live pawn (dead / logged out) keep `lat`/`lon`/`ccc` at zero. `inventory` is empty and pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent (the profile carries neither).
|
|
318
318
|
- Otherwise it's treated as a wrapped `(profile, objects)` pair pointing at an in-world `PlayerPawn` → `_player_from_object` runs: fills location, inventory, pawn-state flags, body/hair cosmetics, death timestamps, and `active` from `SavedLastTimeHadController`.
|
|
319
319
|
|
|
320
320
|
For the richest output, hand `export_players` **both**, assemble a wrapper for each player that carries the `Profile` (for offline identity) *and* the in-world pawn (when present) so the merged record gets identity + live state. The current validation script only passes `Profile` instances, which is why fields like `is_sleeping` / `body_colors` / `current_weapon` show up empty in the validation output even though the parser supports them.
|
|
@@ -336,7 +336,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
336
336
|
| `netAddress` | legacy (now populated) | Last client IP ARK persisted (`SavedNetworkAddress` in profile `MyData`). Legacy ASVExport reads the same field (ContentPlayer.cs:157 ASE / :341 ASA). `""` when the profile lacks it (e.g. never-played placeholders). ASA stores a clean IPv4/IPv6 string; some ASE saves store an engine-truncated value (e.g. `"[2001"`) reproduced verbatim, matching legacy. |
|
|
337
337
|
| `steamid`, `dataFile` | legacy | platform net id and `{steamid}.arkprofile` filename |
|
|
338
338
|
| `active` | legacy (now populated) | ISO 8601 datetime of last login, converted from profile `LastLoginTime` or in-world pawn `SavedLastTimeHadController`. Legacy schema reserved the field but the old C# exporter only filled it for in-world pawns; the parser fills it for profiles too. `null` when neither source is present. |
|
|
339
|
-
| `lat`, `lon`, `ccc` | legacy (now populated) | In-world position
|
|
339
|
+
| `lat`, `lon`, `ccc` | legacy (now populated) | In-world position. Built directly from a `PlayerPawn` GameObject when one is passed, and for profile-sourced records the parser joins `PlayerDataID == LinkedPlayerDataID` to the world save's player pawns and pulls the matched pawn's `.location`. Records whose player has no live pawn (dead / logged out / cluster-only) keep the legacy `0` / `"0 0 0"` placeholders since there is no world position to report. |
|
|
340
340
|
| `inventory` | legacy (now populated) | Items from the pawn's `MyInventoryComponent` when built from an in-world pawn. Empty list otherwise. |
|
|
341
341
|
| `engram_points` | added | `TotalEngramPoints` |
|
|
342
342
|
| `experience` | added | `ExperiencePoints` (status), integer |
|
|
@@ -365,7 +365,7 @@ For the richest output, hand `export_players` **both**, assemble a wrapper for e
|
|
|
365
365
|
| `members` | legacy | list of `{ign, lvl, playerid, playername, steamid}` built from the allocated players. `lvl` and `steamid` populate from the matching `.arkprofile`; members with no profile (back-filled from the tribe's member list) carry `lvl=0`, `steamid=""`. |
|
|
366
366
|
| `tames`, `structures` | legacy | counts derived from `WorldSave` (creatures + structures whose `TargetingTeam` matches) |
|
|
367
367
|
| `uploadedTames` | legacy | reserved (currently `0`) |
|
|
368
|
-
| `active` | legacy | ISO 8601 datetime of the
|
|
368
|
+
| `active` | legacy | ISO 8601 datetime, legacy `ContentTribe.LastActive`: max of the tribe file's write time (`.arktribe` mtime; world-save mtime for in-save tribes) and the allocated members' last-active times (`LastLoginTime` via the save anchor), discarding future values. `null` when no past candidate exists. Tribe-log "Day N" stamps are game-calendar time and are deliberately not used. |
|
|
369
369
|
| `dataFile` | legacy | `"{tribeid}.arktribe"` filename pattern. |
|
|
370
370
|
| `owner_id` | added | `OwnerPlayerDataID` / parser `Tribe.owner_player_id` |
|
|
371
371
|
| `owner_name` | added | `OwnerPlayerName` (object form only; parser-tribe form has no equivalent) |
|
|
@@ -681,6 +681,15 @@ Schema policy:
|
|
|
681
681
|
|
|
682
682
|
ASA worldsave property layouts differ between **v13** (`TheIsland_WP` and older single-player saves, legacy `AsaSavegameToolkit`-style `dataSize + position + typeRef + byte` body) and **v14+** (current production ASA, marker-based body). Both are parsed by version-aware property readers; `WorldSave.version` is the source of truth.
|
|
683
683
|
|
|
684
|
+
### Parse diagnostics (logging)
|
|
685
|
+
|
|
686
|
+
The parser logs through the standard `logging` module on the `arkparser.*` logger hierarchy. At DEBUG level it reports recoverable parse anomalies, for example ASE objects whose property block terminates early (the run of successfully parsed properties is kept and the remaining bytes are preserved on `GameObject.extra_data`):
|
|
687
|
+
|
|
688
|
+
```python
|
|
689
|
+
import logging
|
|
690
|
+
logging.getLogger("arkparser").setLevel(logging.DEBUG)
|
|
691
|
+
```
|
|
692
|
+
|
|
684
693
|
### Known limitation: ASA cryopod property blocks are partially decoded
|
|
685
694
|
|
|
686
695
|
ASA cryopods (`PrimalItem_WeaponEmptyCryopod_C`, `SoulTrap`, `Vivarium`, `DinoBall`) store the embedded creature snapshot inside the item's `CustomItemDatas.CustomDataBytes.ByteArrays[0]` as a **zlib + custom-RLE compressed `AsaDataStore` blob**. The parser surfaces the snapshot via the simpler `CustomDataStrings` / `CustomDataFloats` accessors, exposing **species, tamed name, level, color regions, and `CurrentStatusValues[0..11]`** on each cryopodded creature.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Regression tests for export helpers against real parser objects."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from arkparser import Profile, Tribe, WorldSave
|
|
11
|
+
from arkparser.export import (
|
|
12
|
+
export_all,
|
|
13
|
+
export_players,
|
|
14
|
+
export_structures,
|
|
15
|
+
export_tamed,
|
|
16
|
+
export_to_files,
|
|
17
|
+
export_tribe_logs,
|
|
18
|
+
export_tribes,
|
|
19
|
+
export_wild,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_EXAMPLES = Path(__file__).parent.parent / "references" / "examples"
|
|
23
|
+
_ASE_SCORCHED_EARTH = _EXAMPLES / "ase" / "maps" / "scorchedearth" / "ScorchedEarth_P.ark"
|
|
24
|
+
_MAP_DUMP_EXTINCTION = Path(__file__).parent.parent / "references" / "map_dumps" / "evolved" / "extinction"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _first_dump_file(pattern: str) -> Path:
|
|
28
|
+
"""First map-dump fixture matching ``pattern``, else skip the test."""
|
|
29
|
+
if _MAP_DUMP_EXTINCTION.is_dir():
|
|
30
|
+
files = sorted(_MAP_DUMP_EXTINCTION.glob(pattern))
|
|
31
|
+
if files:
|
|
32
|
+
return files[0]
|
|
33
|
+
pytest.skip(f"no {pattern} fixture under {_MAP_DUMP_EXTINCTION}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _profile_with_last_login() -> Profile:
|
|
37
|
+
"""First map-dump profile carrying LastLoginTime, else skip the test."""
|
|
38
|
+
if _MAP_DUMP_EXTINCTION.is_dir():
|
|
39
|
+
for path in sorted(_MAP_DUMP_EXTINCTION.glob("*.arkprofile"))[:50]:
|
|
40
|
+
try:
|
|
41
|
+
profile = Profile.load(path)
|
|
42
|
+
except Exception: # noqa: BLE001 - corrupt fixture must not abort
|
|
43
|
+
continue
|
|
44
|
+
if profile.last_login_time and profile.player_id:
|
|
45
|
+
return profile
|
|
46
|
+
pytest.skip(f"no profile with LastLoginTime under {_MAP_DUMP_EXTINCTION}")
|
|
47
|
+
|
|
48
|
+
_ASV_NAMES = {
|
|
49
|
+
"ASV_Tamed",
|
|
50
|
+
"ASV_Wild",
|
|
51
|
+
"ASV_Players",
|
|
52
|
+
"ASV_Tribes",
|
|
53
|
+
"ASV_Structures",
|
|
54
|
+
"ASV_TribeLogs",
|
|
55
|
+
"ASV_MapStructures",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture(scope="session")
|
|
60
|
+
def ase_export_world_save() -> WorldSave:
|
|
61
|
+
if not _ASE_SCORCHED_EARTH.exists():
|
|
62
|
+
pytest.skip(f"Fixture not available: {_ASE_SCORCHED_EARTH}")
|
|
63
|
+
return WorldSave.load(_ASE_SCORCHED_EARTH)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_export_tamed_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
67
|
+
exported = export_tamed(ase_export_world_save)
|
|
68
|
+
assert len(exported) == len(ase_export_world_save.get_tamed_creatures())
|
|
69
|
+
assert any(creature.get("name") == "escobar" for creature in exported)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_export_wild_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
73
|
+
exported = export_wild(ase_export_world_save)
|
|
74
|
+
assert len(exported) == len(ase_export_world_save.get_wild_creatures())
|
|
75
|
+
assert all(creature.get("tamer", "") == "" for creature in exported[:100])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_export_structures_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
79
|
+
exported = export_structures(ase_export_world_save)
|
|
80
|
+
assert len(exported) == len(ase_export_world_save.get_structures())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_export_players_uses_profile_parser(ase_profile_path: Path) -> None:
|
|
84
|
+
profile = Profile.load(ase_profile_path)
|
|
85
|
+
exported = export_players(type("Holder", (), {"profiles": [profile]})())
|
|
86
|
+
assert exported[0]["playerid"] == profile.player_id
|
|
87
|
+
assert exported[0]["steam"] == profile.player_name
|
|
88
|
+
assert exported[0]["name"] == profile.character_name
|
|
89
|
+
assert exported[0]["sex"] == ("Female" if profile.is_female else "Male")
|
|
90
|
+
assert exported[0]["tribeid"] == profile.tribe_id
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_export_tribes_uses_tribe_parser(ase_tribe_path: Path) -> None:
|
|
94
|
+
tribe = Tribe.load(ase_tribe_path)
|
|
95
|
+
exported = export_tribes(type("Holder", (), {"tribes": [tribe]})())
|
|
96
|
+
assert exported[0]["tribeid"] == tribe.tribe_id
|
|
97
|
+
assert exported[0]["tribe"] == tribe.name
|
|
98
|
+
assert exported[0]["players"] == tribe.member_count
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_export_tribes_active_is_file_mtime(tmp_path: Path) -> None:
|
|
102
|
+
"""Tribe ``active`` mirrors legacy ContentTribe.LastActive: the .arktribe
|
|
103
|
+
file's write time, never a value derived from in-game log day numbers
|
|
104
|
+
(those use the game calendar, not real seconds, and produced far-future
|
|
105
|
+
dates)."""
|
|
106
|
+
tribe_path = _first_dump_file("*.arktribe")
|
|
107
|
+
copy = tmp_path / tribe_path.name
|
|
108
|
+
shutil.copy2(tribe_path, copy)
|
|
109
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
110
|
+
stamp = dt.datetime(2024, 5, 1, 12, 0, 0, tzinfo=local_tz)
|
|
111
|
+
os.utime(copy, (stamp.timestamp(), stamp.timestamp()))
|
|
112
|
+
tribe = Tribe.load(copy)
|
|
113
|
+
now = dt.datetime.now(tz=local_tz)
|
|
114
|
+
holder = type("Holder", (), {"tribes": [tribe], "file_mtime": now, "game_time": 5_000.0})()
|
|
115
|
+
exported = export_tribes(holder)
|
|
116
|
+
rec = next(r for r in exported if r["tribeid"] == tribe.tribe_id)
|
|
117
|
+
assert rec["active"] is not None
|
|
118
|
+
active = dt.datetime.fromisoformat(rec["active"])
|
|
119
|
+
assert active.timestamp() == pytest.approx(stamp.timestamp(), abs=2)
|
|
120
|
+
assert active <= now
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_export_tribes_solo_active_from_profile_last_login() -> None:
|
|
124
|
+
"""A profile-only (solo) tribe gets ``active`` from the member's
|
|
125
|
+
LastLoginTime converted through the save anchor, matching legacy
|
|
126
|
+
ContentTribe.LastActive over allocated players."""
|
|
127
|
+
profile = _profile_with_last_login()
|
|
128
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
129
|
+
now = dt.datetime.now(tz=local_tz)
|
|
130
|
+
game_time = float(profile.last_login_time) + 3_600.0
|
|
131
|
+
holder = type(
|
|
132
|
+
"Holder",
|
|
133
|
+
(),
|
|
134
|
+
{"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
|
|
135
|
+
)()
|
|
136
|
+
exported = export_tribes(holder)
|
|
137
|
+
rec = next(r for r in exported if r["tribeid"] == profile.player_id)
|
|
138
|
+
assert rec["active"] is not None
|
|
139
|
+
active = dt.datetime.fromisoformat(rec["active"])
|
|
140
|
+
expected = now - dt.timedelta(seconds=3_600)
|
|
141
|
+
assert active.timestamp() == pytest.approx(expected.timestamp(), abs=2)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_export_tribes_active_never_future() -> None:
|
|
145
|
+
"""Future candidates are discarded (legacy filters d <= DateTime.Now)."""
|
|
146
|
+
profile = _profile_with_last_login()
|
|
147
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
148
|
+
now = dt.datetime.now(tz=local_tz)
|
|
149
|
+
game_time = max(float(profile.last_login_time) - 100_000_000.0, 1.0)
|
|
150
|
+
holder = type(
|
|
151
|
+
"Holder",
|
|
152
|
+
(),
|
|
153
|
+
{"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
|
|
154
|
+
)()
|
|
155
|
+
exported = export_tribes(holder)
|
|
156
|
+
rec = next(r for r in exported if r["tribeid"] == profile.player_id)
|
|
157
|
+
assert rec["active"] is None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_export_tribe_logs_uses_tribe_parser(ase_tribe_path: Path) -> None:
|
|
161
|
+
tribe = Tribe.load(ase_tribe_path)
|
|
162
|
+
exported = export_tribe_logs(type("Holder", (), {"tribes": [tribe]})())
|
|
163
|
+
assert exported[0]["tribeid"] == tribe.tribe_id
|
|
164
|
+
assert exported[0]["tribe"] == tribe.name
|
|
165
|
+
assert exported[0]["logs"] == tribe.log_entries
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_export_all_uses_asv_names(ase_export_world_save: WorldSave) -> None:
|
|
169
|
+
exported = export_all(ase_export_world_save)
|
|
170
|
+
assert set(exported) == _ASV_NAMES
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_export_to_files_uses_asv_names(
|
|
174
|
+
ase_export_world_save: WorldSave, tmp_path: Path
|
|
175
|
+
) -> None:
|
|
176
|
+
created = export_to_files(ase_export_world_save, tmp_path)
|
|
177
|
+
assert {path.name for path in created} == {f"{name}.json" for name in _ASV_NAMES}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
"""Regression tests for export helpers against real parser objects."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from arkparser import Profile, Tribe, WorldSave
|
|
8
|
-
from arkparser.export import (
|
|
9
|
-
export_all,
|
|
10
|
-
export_players,
|
|
11
|
-
export_structures,
|
|
12
|
-
export_tamed,
|
|
13
|
-
export_to_files,
|
|
14
|
-
export_tribe_logs,
|
|
15
|
-
export_tribes,
|
|
16
|
-
export_wild,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
_EXAMPLES = Path(__file__).parent.parent / "references" / "examples"
|
|
20
|
-
_ASE_SCORCHED_EARTH = _EXAMPLES / "ase" / "maps" / "scorchedearth" / "ScorchedEarth_P.ark"
|
|
21
|
-
|
|
22
|
-
_ASV_NAMES = {
|
|
23
|
-
"ASV_Tamed",
|
|
24
|
-
"ASV_Wild",
|
|
25
|
-
"ASV_Players",
|
|
26
|
-
"ASV_Tribes",
|
|
27
|
-
"ASV_Structures",
|
|
28
|
-
"ASV_TribeLogs",
|
|
29
|
-
"ASV_MapStructures",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@pytest.fixture(scope="session")
|
|
34
|
-
def ase_export_world_save() -> WorldSave:
|
|
35
|
-
if not _ASE_SCORCHED_EARTH.exists():
|
|
36
|
-
pytest.skip(f"Fixture not available: {_ASE_SCORCHED_EARTH}")
|
|
37
|
-
return WorldSave.load(_ASE_SCORCHED_EARTH)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_export_tamed_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
41
|
-
exported = export_tamed(ase_export_world_save)
|
|
42
|
-
assert len(exported) == len(ase_export_world_save.get_tamed_creatures())
|
|
43
|
-
assert any(creature.get("name") == "escobar" for creature in exported)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_export_wild_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
47
|
-
exported = export_wild(ase_export_world_save)
|
|
48
|
-
assert len(exported) == len(ase_export_world_save.get_wild_creatures())
|
|
49
|
-
assert all(creature.get("tamer", "") == "" for creature in exported[:100])
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_export_structures_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
53
|
-
exported = export_structures(ase_export_world_save)
|
|
54
|
-
assert len(exported) == len(ase_export_world_save.get_structures())
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_export_players_uses_profile_parser(ase_profile_path: Path) -> None:
|
|
58
|
-
profile = Profile.load(ase_profile_path)
|
|
59
|
-
exported = export_players(type("Holder", (), {"profiles": [profile]})())
|
|
60
|
-
assert exported[0]["playerid"] == profile.player_id
|
|
61
|
-
assert exported[0]["steam"] == profile.player_name
|
|
62
|
-
assert exported[0]["name"] == profile.character_name
|
|
63
|
-
assert exported[0]["sex"] == ("Female" if profile.is_female else "Male")
|
|
64
|
-
assert exported[0]["tribeid"] == profile.tribe_id
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_export_tribes_uses_tribe_parser(ase_tribe_path: Path) -> None:
|
|
68
|
-
tribe = Tribe.load(ase_tribe_path)
|
|
69
|
-
exported = export_tribes(type("Holder", (), {"tribes": [tribe]})())
|
|
70
|
-
assert exported[0]["tribeid"] == tribe.tribe_id
|
|
71
|
-
assert exported[0]["tribe"] == tribe.name
|
|
72
|
-
assert exported[0]["players"] == tribe.member_count
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_export_tribe_logs_uses_tribe_parser(ase_tribe_path: Path) -> None:
|
|
76
|
-
tribe = Tribe.load(ase_tribe_path)
|
|
77
|
-
exported = export_tribe_logs(type("Holder", (), {"tribes": [tribe]})())
|
|
78
|
-
assert exported[0]["tribeid"] == tribe.tribe_id
|
|
79
|
-
assert exported[0]["tribe"] == tribe.name
|
|
80
|
-
assert exported[0]["logs"] == tribe.log_entries
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_export_all_uses_asv_names(ase_export_world_save: WorldSave) -> None:
|
|
84
|
-
exported = export_all(ase_export_world_save)
|
|
85
|
-
assert set(exported) == _ASV_NAMES
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def test_export_to_files_uses_asv_names(
|
|
89
|
-
ase_export_world_save: WorldSave, tmp_path: Path
|
|
90
|
-
) -> None:
|
|
91
|
-
created = export_to_files(ase_export_world_save, tmp_path)
|
|
92
|
-
assert {path.name for path in created} == {f"{name}.json" for name in _ASV_NAMES}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|