arkparser 0.6.0__tar.gz → 0.7.0__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.0}/PKG-INFO +4 -4
- {arkparser-0.6.0 → arkparser-0.7.0}/README.md +3 -3
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/__init__.py +1 -1
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/export.py +108 -56
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/PKG-INFO +4 -4
- {arkparser-0.6.0 → arkparser-0.7.0}/pyproject.toml +1 -1
- arkparser-0.7.0/tests/test_export.py +177 -0
- arkparser-0.6.0/tests/test_export.py +0 -92
- {arkparser-0.6.0 → arkparser-0.7.0}/LICENSE +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/binary_reader.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/exceptions.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/map_config.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/normalization.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/types.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/version_detection.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/data_models.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/base.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/cloud_inventory.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/profile.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/tribe.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/world_save.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/game_objects/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/game_objects/container.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/game_objects/game_object.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/game_objects/location.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/base.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/byte_property.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/compound.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/primitives.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/registry.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/__init__.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/base.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/colors.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/misc.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/property_list.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/registry.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/vectors.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/SOURCES.txt +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/dependency_links.txt +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/requires.txt +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/top_level.txt +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/setup.cfg +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_asa_header_position.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_asa_name_table.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_ase_cluster_drift.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_binary_reader.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_binary_reader_layouts.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_cloud_export.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_cloud_inventory.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_cryopod_export.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_current_stats.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_data_models.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_game_objects.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_golden_exports.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_lazy_properties.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_profile.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_review_fixes.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_tribe.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_v13_property_layouts.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_version_detection.py +0 -0
- {arkparser-0.6.0 → arkparser-0.7.0}/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.0
|
|
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) |
|
|
@@ -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) |
|
|
@@ -2058,6 +2058,8 @@ def _player_from_profile(
|
|
|
2058
2058
|
profile: Profile,
|
|
2059
2059
|
save: t.Any = None,
|
|
2060
2060
|
pawn_status_by_id: dict[int, t.Any] | None = None,
|
|
2061
|
+
pawn_by_data_id: dict[int, t.Any] | None = None,
|
|
2062
|
+
map_config: MapConfig | None = None,
|
|
2061
2063
|
) -> dict[str, t.Any]:
|
|
2062
2064
|
stat_points = [_int(profile.get_stat(i)["added"]) for i in range(12)]
|
|
2063
2065
|
gamertag = profile.player_name or ""
|
|
@@ -2069,8 +2071,18 @@ def _player_from_profile(
|
|
|
2069
2071
|
# not in the profile. Resolve via PlayerDataID -> pawn -> status. Absent
|
|
2070
2072
|
# when the player has no spawned body (never spawned / corpse cleared).
|
|
2071
2073
|
status = None
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
+
pawn = None
|
|
2075
|
+
if profile.player_id:
|
|
2076
|
+
pid = int(profile.player_id)
|
|
2077
|
+
if pawn_status_by_id:
|
|
2078
|
+
status = pawn_status_by_id.get(pid)
|
|
2079
|
+
if pawn_by_data_id:
|
|
2080
|
+
pawn = pawn_by_data_id.get(pid)
|
|
2081
|
+
|
|
2082
|
+
# The profile carries no world position. When the player has a live pawn in
|
|
2083
|
+
# the save, pull lat/lon/ccc from it; otherwise keep the legacy 0/0 zeroes
|
|
2084
|
+
# (dead / logged-out players have no location to report).
|
|
2085
|
+
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
2086
|
|
|
2075
2087
|
out: dict[str, t.Any] = {
|
|
2076
2088
|
"playerid": profile.player_id or 0,
|
|
@@ -2080,11 +2092,11 @@ def _player_from_profile(
|
|
|
2080
2092
|
"tribe": profile.tribe_name or "",
|
|
2081
2093
|
"sex": "Female" if profile.is_female is True else "Male",
|
|
2082
2094
|
"lvl": profile.level,
|
|
2083
|
-
"lat":
|
|
2084
|
-
"lon":
|
|
2095
|
+
"lat": gps["lat"],
|
|
2096
|
+
"lon": gps["lon"],
|
|
2085
2097
|
**_flat_stats(stat_points),
|
|
2086
2098
|
"active": active_dt.isoformat() if active_dt is not None else None,
|
|
2087
|
-
"ccc": "
|
|
2099
|
+
"ccc": gps["ccc"],
|
|
2088
2100
|
"achievements": [],
|
|
2089
2101
|
"inventory": [],
|
|
2090
2102
|
"netAddress": _str(profile.last_net_address),
|
|
@@ -2168,16 +2180,25 @@ def _player_from_object(
|
|
|
2168
2180
|
return _compact(data, LEGACY_PLAYER_KEYS)
|
|
2169
2181
|
|
|
2170
2182
|
|
|
2171
|
-
def _player_status_by_data_id(
|
|
2172
|
-
|
|
2183
|
+
def _player_status_by_data_id(
|
|
2184
|
+
save: t.Any,
|
|
2185
|
+
lookup: dict[t.Any, t.Any],
|
|
2186
|
+
) -> tuple[dict[int, t.Any], dict[int, t.Any]]:
|
|
2187
|
+
"""Index per-player pawn + status component by ``LinkedPlayerDataID``.
|
|
2173
2188
|
|
|
2174
2189
|
Walks every ``PlayerPawnTest_*_C`` / ``PlayerCharacter_*`` in the world
|
|
2175
2190
|
save, reads its ``LinkedPlayerDataID``, follows ``MyCharacterStatusComponent``
|
|
2176
|
-
via ``lookup``, and stores the resolved status object
|
|
2177
|
-
player exports surface live
|
|
2178
|
-
|
|
2191
|
+
via ``lookup``, and stores both the resolved status object and the pawn
|
|
2192
|
+
itself. The status index lets profile-based player exports surface live
|
|
2193
|
+
HP/stamina/food/etc.; the pawn index lets them surface live lat/lon/ccc
|
|
2194
|
+
(the pawn carries ``.location``, the profile does not), without legacy
|
|
2195
|
+
ASVPack having to do the same join.
|
|
2196
|
+
|
|
2197
|
+
Returns ``(status_by_id, pawn_by_id)``; a pawn appears in ``pawn_by_id``
|
|
2198
|
+
even when its status component fails to resolve.
|
|
2179
2199
|
"""
|
|
2180
|
-
|
|
2200
|
+
status_out: dict[int, t.Any] = {}
|
|
2201
|
+
pawn_out: dict[int, t.Any] = {}
|
|
2181
2202
|
objects = getattr(save, "objects", None) or []
|
|
2182
2203
|
for obj in objects:
|
|
2183
2204
|
cn = str(getattr(obj, "class_name", "") or "")
|
|
@@ -2186,10 +2207,11 @@ def _player_status_by_data_id(save: t.Any, lookup: dict[t.Any, t.Any]) -> dict[i
|
|
|
2186
2207
|
pid = _int(_prop(obj, "LinkedPlayerDataID"))
|
|
2187
2208
|
if not pid:
|
|
2188
2209
|
continue
|
|
2210
|
+
pawn_out[pid] = obj
|
|
2189
2211
|
status = _status_for(obj, lookup)
|
|
2190
2212
|
if status is not None:
|
|
2191
|
-
|
|
2192
|
-
return
|
|
2213
|
+
status_out[pid] = status
|
|
2214
|
+
return status_out, pawn_out
|
|
2193
2215
|
|
|
2194
2216
|
|
|
2195
2217
|
def _cluster_items_by_xuid(
|
|
@@ -2226,6 +2248,7 @@ def _player_record_for(
|
|
|
2226
2248
|
entry: t.Any,
|
|
2227
2249
|
save: t.Any,
|
|
2228
2250
|
pawn_status_by_id: dict[int, t.Any],
|
|
2251
|
+
pawn_by_data_id: dict[int, t.Any],
|
|
2229
2252
|
lookup: dict[t.Any, t.Any],
|
|
2230
2253
|
map_config: MapConfig | None,
|
|
2231
2254
|
) -> tuple[dict[str, t.Any] | None, list[str]]:
|
|
@@ -2237,7 +2260,7 @@ def _player_record_for(
|
|
|
2237
2260
|
"""
|
|
2238
2261
|
join_keys: list[str] = []
|
|
2239
2262
|
if isinstance(entry, Profile):
|
|
2240
|
-
record = _player_from_profile(entry, save, pawn_status_by_id)
|
|
2263
|
+
record = _player_from_profile(entry, save, pawn_status_by_id, pawn_by_data_id, map_config)
|
|
2241
2264
|
if entry.source_path is not None and entry.source_path.stem:
|
|
2242
2265
|
join_keys.append(entry.source_path.stem)
|
|
2243
2266
|
if entry.unique_id:
|
|
@@ -2312,7 +2335,7 @@ def _iter_players(
|
|
|
2312
2335
|
"""Yield ASV_Players records one at a time (streaming form of export_players)."""
|
|
2313
2336
|
profiles = _collection(save, "profiles", Profile)
|
|
2314
2337
|
lookup = _save_lookup(save)
|
|
2315
|
-
pawn_status_by_id = _player_status_by_data_id(save, lookup)
|
|
2338
|
+
pawn_status_by_id, pawn_by_data_id = _player_status_by_data_id(save, lookup)
|
|
2316
2339
|
cluster_items = _cluster_items_by_xuid(cluster_inventories, save) if cluster_inventories else {}
|
|
2317
2340
|
# Legacy emits each player's tribeid as the CONTAINING tribe's id (the one
|
|
2318
2341
|
# whose member list / team claims them), not the profile's own field. The
|
|
@@ -2321,7 +2344,9 @@ def _iter_players(
|
|
|
2321
2344
|
profile_tribeid = allocation["profile_tribeid"]
|
|
2322
2345
|
tribe_names = allocation["names"]
|
|
2323
2346
|
for entry in profiles:
|
|
2324
|
-
record, join_keys = _player_record_for(
|
|
2347
|
+
record, join_keys = _player_record_for(
|
|
2348
|
+
entry, save, pawn_status_by_id, pawn_by_data_id, lookup, map_config
|
|
2349
|
+
)
|
|
2325
2350
|
if record is None:
|
|
2326
2351
|
continue
|
|
2327
2352
|
if isinstance(entry, Profile):
|
|
@@ -2388,45 +2413,67 @@ def _tribe_members_from_parser(
|
|
|
2388
2413
|
return out
|
|
2389
2414
|
|
|
2390
2415
|
|
|
2391
|
-
def
|
|
2392
|
-
"""
|
|
2416
|
+
def _tribe_file_date(tribe: t.Any, save: t.Any) -> dt.datetime | None:
|
|
2417
|
+
"""Wall-clock write time of a tribe's backing file (legacy TribeFileDate).
|
|
2393
2418
|
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
save's anchor (``file_mtime + (max_log_seconds - game_time)``) gives a
|
|
2398
|
-
real wall-clock timestamp.
|
|
2399
|
-
|
|
2400
|
-
Returns ``None`` when no parseable log entry is found or the save lacks
|
|
2401
|
-
conversion anchors.
|
|
2419
|
+
File-backed tribes use the .arktribe mtime (ContentContainer.cs:1812);
|
|
2420
|
+
tribes parsed out of the world save fall back to the save's own mtime
|
|
2421
|
+
(ContentContainer.cs:734, GameSaveTime).
|
|
2402
2422
|
"""
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
if not isinstance(raw, str):
|
|
2406
|
-
continue
|
|
2407
|
-
m = _LOG_RE.match(raw.strip())
|
|
2408
|
-
if not m:
|
|
2409
|
-
continue
|
|
2410
|
-
try:
|
|
2411
|
-
day = int(m.group(1))
|
|
2412
|
-
except (TypeError, ValueError):
|
|
2413
|
-
continue
|
|
2414
|
-
parts = m.group(2).split(":")
|
|
2415
|
-
if len(parts) < 2:
|
|
2416
|
-
continue
|
|
2423
|
+
src = getattr(tribe, "source_path", None) if tribe is not None else None
|
|
2424
|
+
if src is not None:
|
|
2417
2425
|
try:
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2426
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
2427
|
+
return dt.datetime.fromtimestamp(src.stat().st_mtime, tz=local_tz)
|
|
2428
|
+
except OSError:
|
|
2429
|
+
logger.debug("stat failed for tribe file %s", src)
|
|
2430
|
+
return getattr(save, "file_mtime", None) if save is not None else None
|
|
2431
|
+
|
|
2432
|
+
|
|
2433
|
+
def _tribe_active_iso(
|
|
2434
|
+
file_date: t.Any,
|
|
2435
|
+
member_ids: t.Iterable[int],
|
|
2436
|
+
profile_active: dict[int, dt.datetime],
|
|
2437
|
+
) -> str | None:
|
|
2438
|
+
"""Legacy ContentTribe.LastActive (ContentTribe.cs:35-49): the max of the
|
|
2439
|
+
tribe file date and the members' last-active datetimes, discarding values
|
|
2440
|
+
in the future. Tribe-log "Day N" stamps are game-calendar time, not real
|
|
2441
|
+
seconds, so they cannot be converted with the save anchor and are unused.
|
|
2442
|
+
|
|
2443
|
+
Returns ``None`` when no past candidate exists.
|
|
2444
|
+
"""
|
|
2445
|
+
assert isinstance(profile_active, dict), "profile_active must be a dict"
|
|
2446
|
+
candidates: list[dt.datetime] = []
|
|
2447
|
+
if isinstance(file_date, dt.datetime):
|
|
2448
|
+
candidates.append(file_date if file_date.tzinfo else file_date.astimezone())
|
|
2449
|
+
for i, pid in enumerate(member_ids):
|
|
2450
|
+
assert i < _MAX_TRIBE_MEMBERS, "tribe member list exceeded bound"
|
|
2451
|
+
active = profile_active.get(_int(pid))
|
|
2452
|
+
if active is not None:
|
|
2453
|
+
candidates.append(active if active.tzinfo else active.astimezone())
|
|
2454
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
2455
|
+
past = [c for c in candidates if c <= now]
|
|
2456
|
+
if not past:
|
|
2427
2457
|
return None
|
|
2428
|
-
|
|
2429
|
-
|
|
2458
|
+
return max(past).isoformat()
|
|
2459
|
+
|
|
2460
|
+
|
|
2461
|
+
def _profile_actives(profiles: list[Profile], save: t.Any) -> dict[int, dt.datetime]:
|
|
2462
|
+
"""``player_id -> last-active datetime`` for tribe activity rollups.
|
|
2463
|
+
|
|
2464
|
+
Mirrors legacy ContentPlayer.LastActiveDateTime: the profile's
|
|
2465
|
+
LastLoginTime converted through the save anchor.
|
|
2466
|
+
"""
|
|
2467
|
+
assert isinstance(profiles, list), "profiles must be a list"
|
|
2468
|
+
out: dict[int, dt.datetime] = {}
|
|
2469
|
+
for prof in profiles:
|
|
2470
|
+
pid = _int(prof.player_id)
|
|
2471
|
+
if not pid:
|
|
2472
|
+
continue
|
|
2473
|
+
active = _approx_real_datetime(prof.last_login_time, save)
|
|
2474
|
+
if active is not None:
|
|
2475
|
+
out[pid] = active
|
|
2476
|
+
return out
|
|
2430
2477
|
|
|
2431
2478
|
|
|
2432
2479
|
def _build_profile_index(save: t.Any) -> dict[int, Profile]:
|
|
@@ -2455,7 +2502,7 @@ def _tribe_from_parser(
|
|
|
2455
2502
|
"tames": c.get("tames", 0),
|
|
2456
2503
|
"uploadedTames": 0,
|
|
2457
2504
|
"structures": c.get("structures", 0),
|
|
2458
|
-
"active":
|
|
2505
|
+
"active": _tribe_file_date(tribe, save),
|
|
2459
2506
|
"dataFile": f"{tid}.arktribe" if tid else "",
|
|
2460
2507
|
"owner_id": tribe.owner_player_id or 0,
|
|
2461
2508
|
"alliance_ids": tribe.alliance_ids,
|
|
@@ -2507,7 +2554,7 @@ def _tribe_from_object(
|
|
|
2507
2554
|
"tames": c.get("tames", 0),
|
|
2508
2555
|
"uploadedTames": 0,
|
|
2509
2556
|
"structures": c.get("structures", 0),
|
|
2510
|
-
"active":
|
|
2557
|
+
"active": _tribe_file_date(None, save),
|
|
2511
2558
|
"dataFile": f"{tribe_id}.arktribe" if tribe_id else "",
|
|
2512
2559
|
"owner_id": owner_id,
|
|
2513
2560
|
"owner_name": _str(_prop(obj, "OwnerPlayerName")),
|
|
@@ -2725,6 +2772,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2725
2772
|
member_name.setdefault(pid, mname)
|
|
2726
2773
|
file_tribe_ids = set(rich) | {_UNCLAIMED_TRIBE_ID, _ABANDONED_TRIBE_ID}
|
|
2727
2774
|
profiles = _profile_entries(save)
|
|
2775
|
+
profile_active = _profile_actives(profiles, save)
|
|
2728
2776
|
profile_tribeid, players_by_tribe = _allocate_profiles(profiles, file_tribe_ids, member_index)
|
|
2729
2777
|
for prof in profiles:
|
|
2730
2778
|
pid = _int(prof.player_id)
|
|
@@ -2750,6 +2798,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2750
2798
|
"logs": logs,
|
|
2751
2799
|
"counts": counts,
|
|
2752
2800
|
"profile_index": profile_index,
|
|
2801
|
+
"profile_active": profile_active,
|
|
2753
2802
|
"players_by_tribe": players_by_tribe,
|
|
2754
2803
|
"profile_tribeid": profile_tribeid,
|
|
2755
2804
|
"member_stubs": member_stubs,
|
|
@@ -2796,10 +2845,12 @@ def _members_for(tid: int, a: dict[str, t.Any]) -> list[dict[str, t.Any]]:
|
|
|
2796
2845
|
def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
2797
2846
|
"""Final ASV_Tribes record for one assembled tribe id.
|
|
2798
2847
|
|
|
2799
|
-
File-backed tribes reuse the rich record (
|
|
2800
|
-
|
|
2848
|
+
File-backed tribes reuse the rich record (dataFile/owner/alliances) but
|
|
2849
|
+
have ``players``/``members`` overridden to the allocated-player set so
|
|
2801
2850
|
they match the legacy writer (which counts allocated profiles, not the raw
|
|
2802
2851
|
member list). Synthesized stubs carry id + name + world-derived counts.
|
|
2852
|
+
``active`` finalizes here for both shapes: max of the tribe file date and
|
|
2853
|
+
the allocated members' last-active times (legacy ContentTribe.LastActive).
|
|
2803
2854
|
"""
|
|
2804
2855
|
players = a["players_by_tribe"].get(tid, [])
|
|
2805
2856
|
base = a["rich"].get(tid)
|
|
@@ -2807,6 +2858,7 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
|
2807
2858
|
rec = dict(base)
|
|
2808
2859
|
rec["players"] = len(players)
|
|
2809
2860
|
rec["members"] = _members_for(tid, a)
|
|
2861
|
+
rec["active"] = _tribe_active_iso(rec.get("active"), players, a["profile_active"])
|
|
2810
2862
|
return rec
|
|
2811
2863
|
c = a["counts"].get(tid, {})
|
|
2812
2864
|
data: dict[str, t.Any] = {
|
|
@@ -2817,7 +2869,7 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
|
2817
2869
|
"tames": c.get("tames", 0),
|
|
2818
2870
|
"uploadedTames": 0,
|
|
2819
2871
|
"structures": c.get("structures", 0),
|
|
2820
|
-
"active": None,
|
|
2872
|
+
"active": _tribe_active_iso(None, players, a["profile_active"]),
|
|
2821
2873
|
"dataFile": "",
|
|
2822
2874
|
}
|
|
2823
2875
|
return _compact(data, LEGACY_TRIBE_KEYS)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arkparser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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) |
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|