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.
Files changed (64) hide show
  1. {arkparser-0.6.0 → arkparser-0.7.0}/PKG-INFO +4 -4
  2. {arkparser-0.6.0 → arkparser-0.7.0}/README.md +3 -3
  3. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/__init__.py +1 -1
  4. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/export.py +108 -56
  5. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/PKG-INFO +4 -4
  6. {arkparser-0.6.0 → arkparser-0.7.0}/pyproject.toml +1 -1
  7. arkparser-0.7.0/tests/test_export.py +177 -0
  8. arkparser-0.6.0/tests/test_export.py +0 -92
  9. {arkparser-0.6.0 → arkparser-0.7.0}/LICENSE +0 -0
  10. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/__init__.py +0 -0
  11. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/binary_reader.py +0 -0
  12. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/exceptions.py +0 -0
  13. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/map_config.py +0 -0
  14. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/normalization.py +0 -0
  15. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/types.py +0 -0
  16. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/common/version_detection.py +0 -0
  17. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/data_models.py +0 -0
  18. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/__init__.py +0 -0
  19. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/base.py +0 -0
  20. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/cloud_inventory.py +0 -0
  21. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/profile.py +0 -0
  22. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/tribe.py +0 -0
  23. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/files/world_save.py +0 -0
  24. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/game_objects/__init__.py +0 -0
  25. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/game_objects/container.py +0 -0
  26. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/game_objects/game_object.py +0 -0
  27. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/game_objects/location.py +0 -0
  28. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/__init__.py +0 -0
  29. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/base.py +0 -0
  30. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/byte_property.py +0 -0
  31. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/compound.py +0 -0
  32. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/primitives.py +0 -0
  33. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/properties/registry.py +0 -0
  34. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/__init__.py +0 -0
  35. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/base.py +0 -0
  36. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/colors.py +0 -0
  37. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/misc.py +0 -0
  38. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/property_list.py +0 -0
  39. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/registry.py +0 -0
  40. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser/structs/vectors.py +0 -0
  41. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/SOURCES.txt +0 -0
  42. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/dependency_links.txt +0 -0
  43. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/requires.txt +0 -0
  44. {arkparser-0.6.0 → arkparser-0.7.0}/arkparser.egg-info/top_level.txt +0 -0
  45. {arkparser-0.6.0 → arkparser-0.7.0}/setup.cfg +0 -0
  46. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_asa_header_position.py +0 -0
  47. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_asa_name_table.py +0 -0
  48. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_ase_cluster_drift.py +0 -0
  49. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_binary_reader.py +0 -0
  50. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_binary_reader_layouts.py +0 -0
  51. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_cloud_export.py +0 -0
  52. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_cloud_inventory.py +0 -0
  53. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_cryopod_export.py +0 -0
  54. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_current_stats.py +0 -0
  55. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_data_models.py +0 -0
  56. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_game_objects.py +0 -0
  57. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_golden_exports.py +0 -0
  58. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_lazy_properties.py +0 -0
  59. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_profile.py +0 -0
  60. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_review_fixes.py +0 -0
  61. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_tribe.py +0 -0
  62. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_v13_property_layouts.py +0 -0
  63. {arkparser-0.6.0 → arkparser-0.7.0}/tests/test_version_detection.py +0 -0
  64. {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.6.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`). Leaves `lat`/`lon`/`ccc` as zero, `inventory` empty, pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent.
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 when the record was built from a `PlayerPawn` GameObject. Records sourced from profile / cluster files keep the legacy `0` / `"0 0 0"` placeholders since profiles carry no 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 most recent tribe log entry, converted from in-game "Day N, HH:MM:SS" via the save's anchor. `null` when no parseable log entries or anchors are missing. |
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`). Leaves `lat`/`lon`/`ccc` as zero, `inventory` empty, pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent.
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 when the record was built from a `PlayerPawn` GameObject. Records sourced from profile / cluster files keep the legacy `0` / `"0 0 0"` placeholders since profiles carry no 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 most recent tribe log entry, converted from in-game "Day N, HH:MM:SS" via the save's anchor. `null` when no parseable log entries or anchors are missing. |
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) |
@@ -95,4 +95,4 @@ __all__ = [
95
95
  "ArkParseError",
96
96
  ]
97
97
 
98
- __version__ = "0.6.0"
98
+ __version__ = "0.7.0"
@@ -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
- if pawn_status_by_id and profile.player_id:
2073
- status = pawn_status_by_id.get(int(profile.player_id))
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": 0.0,
2084
- "lon": 0.0,
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": "0 0 0",
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(save: t.Any, lookup: dict[t.Any, t.Any]) -> dict[int, t.Any]:
2172
- """Index ``MyCharacterStatusComponent`` per player by ``LinkedPlayerDataID``.
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. Lets profile-based
2177
- player exports surface live HP/stamina/food/etc. without legacy ASVPack
2178
- having to do the same join.
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
- out: dict[int, t.Any] = {}
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
- out[pid] = status
2192
- return out
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(entry, save, pawn_status_by_id, lookup, map_config)
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 _tribe_active_iso(logs: t.Iterable[str], save: t.Any) -> str | None:
2392
- """Derive ``active`` ISO datetime from the most recent tribe log entry.
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
- 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.
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
- max_seconds = 0
2404
- for raw in logs:
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
- h = int(parts[0])
2419
- mi = int(parts[1])
2420
- s = int(parts[2]) if len(parts) > 2 else 0
2421
- except (TypeError, ValueError):
2422
- continue
2423
- sec = day * 86400 + h * 3600 + mi * 60 + s
2424
- if sec > max_seconds:
2425
- max_seconds = sec
2426
- if not max_seconds:
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
- real = _approx_real_datetime(max_seconds, save)
2429
- return real.isoformat() if real is not None else None
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": _tribe_active_iso(tribe.log_entries, save),
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": _tribe_active_iso(_tribe_object_logs(obj), save),
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 (active/dataFile/owner/alliances)
2800
- but have ``players``/``members`` overridden to the allocated-player set so
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.6.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`). Leaves `lat`/`lon`/`ccc` as zero, `inventory` empty, pawn-state flags (`is_sleeping`, `is_dead`, `chibi_levels`, …) absent.
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 when the record was built from a `PlayerPawn` GameObject. Records sourced from profile / cluster files keep the legacy `0` / `"0 0 0"` placeholders since profiles carry no 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 most recent tribe log entry, converted from in-game "Day N, HH:MM:SS" via the save's anchor. `null` when no parseable log entries or anchors are missing. |
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) |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "arkparser"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Pure Python parser for ARK: Survival Evolved and ARK: Survival Ascended save files"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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