arkparser 0.7.4__tar.gz → 0.8.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.7.4 → arkparser-0.8.0}/PKG-INFO +1 -1
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/__init__.py +1 -1
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/export.py +99 -35
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/PKG-INFO +1 -1
- {arkparser-0.7.4 → arkparser-0.8.0}/pyproject.toml +1 -1
- arkparser-0.8.0/tests/test_export.py +367 -0
- arkparser-0.7.4/tests/test_export.py +0 -211
- {arkparser-0.7.4 → arkparser-0.8.0}/LICENSE +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/README.md +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/__init__.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/binary_reader.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/exceptions.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/map_config.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/normalization.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/types.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/common/version_detection.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/data_models.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/__init__.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/base.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/cloud_inventory.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/profile.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/tribe.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/files/world_save.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/game_objects/__init__.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/game_objects/container.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/game_objects/game_object.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/game_objects/location.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/__init__.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/base.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/byte_property.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/compound.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/primitives.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/properties/registry.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/__init__.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/base.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/colors.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/misc.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/property_list.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/registry.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser/structs/vectors.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/SOURCES.txt +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/dependency_links.txt +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/requires.txt +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/arkparser.egg-info/top_level.txt +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/setup.cfg +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_asa_header_position.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_asa_name_table.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_asa_profile_drop.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_ase_cluster_drift.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_binary_reader.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_binary_reader_layouts.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_cloud_export.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_cloud_inventory.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_cryopod_export.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_current_stats.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_data_models.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_game_objects.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_golden_exports.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_lazy_properties.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_profile.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_review_fixes.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_tribe.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_v13_property_layouts.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_version_detection.py +0 -0
- {arkparser-0.7.4 → arkparser-0.8.0}/tests/test_world_save.py +0 -0
|
@@ -326,6 +326,18 @@ LEGACY_TRIBE_KEYS: frozenset[str] = frozenset(
|
|
|
326
326
|
"dataFile",
|
|
327
327
|
}
|
|
328
328
|
)
|
|
329
|
+
# Tribe-record keys that must always emit, even when null/zero. The three
|
|
330
|
+
# last_member_login fields have "null means cannot tell" semantics and a real
|
|
331
|
+
# zero value (login within the final in-game second before the snapshot), so
|
|
332
|
+
# _compact must never prune them. Not part of LEGACY_TRIBE_KEYS: that set
|
|
333
|
+
# names the ASVExport parity contract and stays legacy-only.
|
|
334
|
+
TRIBE_KEEP_KEYS: frozenset[str] = LEGACY_TRIBE_KEYS | frozenset(
|
|
335
|
+
{
|
|
336
|
+
"last_member_login",
|
|
337
|
+
"last_member_login_player_id",
|
|
338
|
+
"last_member_login_game_seconds",
|
|
339
|
+
}
|
|
340
|
+
)
|
|
329
341
|
LEGACY_STRUCT_KEYS: frozenset[str] = frozenset(
|
|
330
342
|
{
|
|
331
343
|
"id",
|
|
@@ -2436,50 +2448,89 @@ def _tribe_file_date(tribe: t.Any, save: t.Any) -> dt.datetime | None:
|
|
|
2436
2448
|
return getattr(save, "file_mtime", None) if save is not None else None
|
|
2437
2449
|
|
|
2438
2450
|
|
|
2439
|
-
def
|
|
2440
|
-
file_date: t.Any,
|
|
2451
|
+
def _tribe_last_login(
|
|
2441
2452
|
member_ids: t.Iterable[int],
|
|
2442
2453
|
profile_active: dict[int, dt.datetime],
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2454
|
+
profile_login_raw: dict[int, float],
|
|
2455
|
+
game_time: float,
|
|
2456
|
+
) -> tuple[str | None, int | None, int | None]:
|
|
2457
|
+
"""Game-domain last-login rollup over a tribe's allocated members.
|
|
2458
|
+
|
|
2459
|
+
Winner is the argmax of raw ``LastLoginTime`` restricted to
|
|
2460
|
+
``login <= game_time``: profiles keep saving after the world snapshot, so
|
|
2461
|
+
logins can exceed the world's game clock; those are dropped, never
|
|
2462
|
+
clamped. Selection and filtering both run in the game-time domain, so the
|
|
2463
|
+
result never depends on any file mtime (the defect that ruins the legacy
|
|
2464
|
+
``active`` as an inactivity signal).
|
|
2465
|
+
|
|
2466
|
+
Returns ``(anchored_iso, player_id, game_seconds)``, all ``None`` when no
|
|
2467
|
+
member passes. ``anchored_iso`` alone may be ``None`` with the other two
|
|
2468
|
+
set, when the save lacks the mtime anchor. ``game_seconds`` is
|
|
2469
|
+
``int(game_time - login)``: a floored, non-negative JSON int.
|
|
2470
|
+
|
|
2471
|
+
Cluster profiles are out of scope by construction: nothing puts a
|
|
2472
|
+
foreign-map Profile into ``save.profiles``, so every candidate here lives
|
|
2473
|
+
on this map's game clock. No defensive logic needed.
|
|
2450
2474
|
"""
|
|
2451
|
-
assert isinstance(
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2475
|
+
assert isinstance(profile_login_raw, dict), "profile_login_raw must be a dict"
|
|
2476
|
+
if game_time <= 0:
|
|
2477
|
+
return None, None, None
|
|
2478
|
+
best_pid: int | None = None
|
|
2479
|
+
best_raw = float("-inf")
|
|
2455
2480
|
for i, pid in enumerate(member_ids):
|
|
2456
2481
|
assert i < _MAX_TRIBE_MEMBERS, "tribe member list exceeded bound"
|
|
2457
|
-
|
|
2458
|
-
if
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
if
|
|
2482
|
+
raw = profile_login_raw.get(_int(pid))
|
|
2483
|
+
if raw is None or raw > game_time or raw <= best_raw:
|
|
2484
|
+
continue
|
|
2485
|
+
best_pid = _int(pid)
|
|
2486
|
+
best_raw = raw
|
|
2487
|
+
if best_pid is None:
|
|
2488
|
+
return None, None, None
|
|
2489
|
+
seconds = int(game_time - best_raw)
|
|
2490
|
+
assert seconds >= 0, "game_seconds must be non-negative after the future filter"
|
|
2491
|
+
anchored = profile_active.get(best_pid)
|
|
2492
|
+
iso = anchored.isoformat() if anchored is not None else None
|
|
2493
|
+
return iso, best_pid, seconds
|
|
2494
|
+
|
|
2495
|
+
|
|
2496
|
+
def _file_date_iso(value: t.Any) -> str | None:
|
|
2497
|
+
"""ISO 8601 for a tribe file date, dropping future values (clock skew,
|
|
2498
|
+
bad rsync). Non-datetime input yields ``None``."""
|
|
2499
|
+
if not isinstance(value, dt.datetime):
|
|
2463
2500
|
return None
|
|
2464
|
-
|
|
2501
|
+
aware = value if value.tzinfo else value.astimezone()
|
|
2502
|
+
assert aware.tzinfo is not None, "file date must be timezone-aware here"
|
|
2503
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
2504
|
+
assert now.tzinfo is not None, "now must be timezone-aware"
|
|
2505
|
+
return aware.isoformat() if aware <= now else None
|
|
2465
2506
|
|
|
2466
2507
|
|
|
2467
|
-
def _profile_actives(
|
|
2468
|
-
|
|
2508
|
+
def _profile_actives(
|
|
2509
|
+
profiles: list[Profile], save: t.Any
|
|
2510
|
+
) -> tuple[dict[int, dt.datetime], dict[int, float]]:
|
|
2511
|
+
"""Per-player last-login maps for tribe activity rollups.
|
|
2469
2512
|
|
|
2470
|
-
|
|
2471
|
-
|
|
2513
|
+
Returns ``(anchored, raw)``: ``anchored`` is ``player_id -> LastLoginTime``
|
|
2514
|
+
converted through the save anchor (legacy ContentPlayer.LastActiveDateTime,
|
|
2515
|
+
inherits .ark mtime error); ``raw`` is ``player_id -> LastLoginTime`` as
|
|
2516
|
+
in-game seconds, untouched by any filesystem state. One pass over
|
|
2517
|
+
``profiles`` fills both.
|
|
2472
2518
|
"""
|
|
2473
2519
|
assert isinstance(profiles, list), "profiles must be a list"
|
|
2474
|
-
|
|
2520
|
+
anchored: dict[int, dt.datetime] = {}
|
|
2521
|
+
raw: dict[int, float] = {}
|
|
2475
2522
|
for prof in profiles:
|
|
2476
2523
|
pid = _int(prof.player_id)
|
|
2477
2524
|
if not pid:
|
|
2478
2525
|
continue
|
|
2526
|
+
login = _float(prof.last_login_time)
|
|
2527
|
+
if login > 0:
|
|
2528
|
+
raw[pid] = login
|
|
2479
2529
|
active = _approx_real_datetime(prof.last_login_time, save)
|
|
2480
2530
|
if active is not None:
|
|
2481
|
-
|
|
2482
|
-
|
|
2531
|
+
anchored[pid] = active
|
|
2532
|
+
assert len(anchored) <= len(profiles) and len(raw) <= len(profiles), "rollup maps exceed input"
|
|
2533
|
+
return anchored, raw
|
|
2483
2534
|
|
|
2484
2535
|
|
|
2485
2536
|
def _build_profile_index(save: t.Any) -> dict[int, Profile]:
|
|
@@ -2785,7 +2836,7 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2785
2836
|
member_name.setdefault(pid, mname)
|
|
2786
2837
|
file_tribe_ids = set(rich) | {_UNCLAIMED_TRIBE_ID, _ABANDONED_TRIBE_ID}
|
|
2787
2838
|
profiles = _profile_entries(save)
|
|
2788
|
-
profile_active = _profile_actives(profiles, save)
|
|
2839
|
+
profile_active, profile_login_raw = _profile_actives(profiles, save)
|
|
2789
2840
|
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
2790
2841
|
profiles, file_tribe_ids, member_index, tribe_members
|
|
2791
2842
|
)
|
|
@@ -2814,6 +2865,8 @@ def _assemble_tribes(save: t.Any) -> dict[str, t.Any]:
|
|
|
2814
2865
|
"counts": counts,
|
|
2815
2866
|
"profile_index": profile_index,
|
|
2816
2867
|
"profile_active": profile_active,
|
|
2868
|
+
"profile_login_raw": profile_login_raw,
|
|
2869
|
+
"game_time": _float(getattr(save, "game_time", 0.0)),
|
|
2817
2870
|
"players_by_tribe": players_by_tribe,
|
|
2818
2871
|
"profile_tribeid": profile_tribeid,
|
|
2819
2872
|
"member_stubs": member_stubs,
|
|
@@ -2862,18 +2915,26 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
|
2862
2915
|
|
|
2863
2916
|
File-backed tribes reuse the rich record (dataFile/owner/alliances) but
|
|
2864
2917
|
have ``players``/``members`` overridden to the allocated-player set so
|
|
2865
|
-
they match the legacy writer
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2918
|
+
they match the legacy writer. ``active`` is the anchored last member
|
|
2919
|
+
login when one exists, else the tribe file date (spec D1-b; deliberate
|
|
2920
|
+
deviation from legacy ContentTribe.LastActive, which was the file mtime
|
|
2921
|
+
on every file-backed tribe). The three ``last_member_login*`` fields are
|
|
2922
|
+
the mtime-immune signal and always emit, null meaning "cannot tell".
|
|
2869
2923
|
"""
|
|
2924
|
+
assert isinstance(a, dict), "assembled tribes must be a dict"
|
|
2870
2925
|
players = a["players_by_tribe"].get(tid, [])
|
|
2926
|
+
iso, login_pid, game_seconds = _tribe_last_login(
|
|
2927
|
+
players, a["profile_active"], a["profile_login_raw"], a["game_time"]
|
|
2928
|
+
)
|
|
2871
2929
|
base = a["rich"].get(tid)
|
|
2872
2930
|
if base is not None:
|
|
2873
2931
|
rec = dict(base)
|
|
2874
2932
|
rec["players"] = len(players)
|
|
2875
2933
|
rec["members"] = _members_for(tid, a)
|
|
2876
|
-
rec["active"] =
|
|
2934
|
+
rec["active"] = iso if iso is not None else _file_date_iso(rec.get("active"))
|
|
2935
|
+
rec["last_member_login"] = iso
|
|
2936
|
+
rec["last_member_login_player_id"] = login_pid
|
|
2937
|
+
rec["last_member_login_game_seconds"] = game_seconds
|
|
2877
2938
|
return rec
|
|
2878
2939
|
c = a["counts"].get(tid, {})
|
|
2879
2940
|
data: dict[str, t.Any] = {
|
|
@@ -2884,10 +2945,13 @@ def _tribe_record(tid: int, a: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
|
2884
2945
|
"tames": c.get("tames", 0),
|
|
2885
2946
|
"uploadedTames": 0,
|
|
2886
2947
|
"structures": c.get("structures", 0),
|
|
2887
|
-
"active":
|
|
2948
|
+
"active": iso,
|
|
2888
2949
|
"dataFile": "",
|
|
2950
|
+
"last_member_login": iso,
|
|
2951
|
+
"last_member_login_player_id": login_pid,
|
|
2952
|
+
"last_member_login_game_seconds": game_seconds,
|
|
2889
2953
|
}
|
|
2890
|
-
return _compact(data,
|
|
2954
|
+
return _compact(data, TRIBE_KEEP_KEYS)
|
|
2891
2955
|
|
|
2892
2956
|
|
|
2893
2957
|
def export_tribes(save: t.Any) -> list[dict[str, t.Any]]:
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Regression tests for export helpers against real parser objects."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import typing as t
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from arkparser import Profile, Tribe, WorldSave
|
|
12
|
+
from arkparser.export import (
|
|
13
|
+
export_all,
|
|
14
|
+
export_players,
|
|
15
|
+
export_structures,
|
|
16
|
+
export_tamed,
|
|
17
|
+
export_to_files,
|
|
18
|
+
export_tribe_logs,
|
|
19
|
+
export_tribes,
|
|
20
|
+
export_wild,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_EXAMPLES = Path(__file__).parent.parent / "references" / "examples"
|
|
24
|
+
_ASE_SCORCHED_EARTH = _EXAMPLES / "ase" / "maps" / "scorchedearth" / "ScorchedEarth_P.ark"
|
|
25
|
+
_MAP_DUMP_EXTINCTION = Path(__file__).parent.parent / "references" / "map_dumps" / "evolved" / "extinction"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _first_dump_file(pattern: str) -> Path:
|
|
29
|
+
"""First map-dump fixture matching ``pattern``, else skip the test."""
|
|
30
|
+
if _MAP_DUMP_EXTINCTION.is_dir():
|
|
31
|
+
files = sorted(_MAP_DUMP_EXTINCTION.glob(pattern))
|
|
32
|
+
if files:
|
|
33
|
+
return files[0]
|
|
34
|
+
pytest.skip(f"no {pattern} fixture under {_MAP_DUMP_EXTINCTION}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _profile_with_last_login() -> Profile:
|
|
38
|
+
"""First map-dump profile carrying LastLoginTime, else skip the test."""
|
|
39
|
+
if _MAP_DUMP_EXTINCTION.is_dir():
|
|
40
|
+
for path in sorted(_MAP_DUMP_EXTINCTION.glob("*.arkprofile"))[:50]:
|
|
41
|
+
try:
|
|
42
|
+
profile = Profile.load(path)
|
|
43
|
+
except Exception: # noqa: BLE001 - corrupt fixture must not abort
|
|
44
|
+
continue
|
|
45
|
+
if profile.last_login_time and profile.player_id:
|
|
46
|
+
return profile
|
|
47
|
+
pytest.skip(f"no profile with LastLoginTime under {_MAP_DUMP_EXTINCTION}")
|
|
48
|
+
|
|
49
|
+
_ASV_NAMES = {
|
|
50
|
+
"ASV_Tamed",
|
|
51
|
+
"ASV_Wild",
|
|
52
|
+
"ASV_Players",
|
|
53
|
+
"ASV_Tribes",
|
|
54
|
+
"ASV_Structures",
|
|
55
|
+
"ASV_TribeLogs",
|
|
56
|
+
"ASV_MapStructures",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture(scope="session")
|
|
61
|
+
def ase_export_world_save() -> WorldSave:
|
|
62
|
+
if not _ASE_SCORCHED_EARTH.exists():
|
|
63
|
+
pytest.skip(f"Fixture not available: {_ASE_SCORCHED_EARTH}")
|
|
64
|
+
return WorldSave.load(_ASE_SCORCHED_EARTH)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_export_tamed_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
68
|
+
exported = export_tamed(ase_export_world_save)
|
|
69
|
+
assert len(exported) == len(ase_export_world_save.get_tamed_creatures())
|
|
70
|
+
assert any(creature.get("name") == "escobar" for creature in exported)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_export_wild_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
74
|
+
exported = export_wild(ase_export_world_save)
|
|
75
|
+
assert len(exported) == len(ase_export_world_save.get_wild_creatures())
|
|
76
|
+
assert all(creature.get("tamer", "") == "" for creature in exported[:100])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_export_structures_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
80
|
+
exported = export_structures(ase_export_world_save)
|
|
81
|
+
assert len(exported) == len(ase_export_world_save.get_structures())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_export_players_uses_profile_parser(ase_profile_path: Path) -> None:
|
|
85
|
+
profile = Profile.load(ase_profile_path)
|
|
86
|
+
exported = export_players(type("Holder", (), {"profiles": [profile]})())
|
|
87
|
+
assert exported[0]["playerid"] == profile.player_id
|
|
88
|
+
assert exported[0]["steam"] == profile.player_name
|
|
89
|
+
assert exported[0]["name"] == profile.character_name
|
|
90
|
+
assert exported[0]["sex"] == ("Female" if profile.is_female else "Male")
|
|
91
|
+
assert exported[0]["tribeid"] == profile.tribe_id
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_export_tribes_uses_tribe_parser(ase_tribe_path: Path) -> None:
|
|
95
|
+
tribe = Tribe.load(ase_tribe_path)
|
|
96
|
+
exported = export_tribes(type("Holder", (), {"tribes": [tribe]})())
|
|
97
|
+
assert exported[0]["tribeid"] == tribe.tribe_id
|
|
98
|
+
assert exported[0]["tribe"] == tribe.name
|
|
99
|
+
assert exported[0]["players"] == tribe.member_count
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_export_tribes_active_falls_back_to_file_mtime(tmp_path: Path) -> None:
|
|
103
|
+
"""With no member login data, ``active`` falls back to the .arktribe
|
|
104
|
+
file's write time (D1-b: login wins when present, file date otherwise).
|
|
105
|
+
The last_member_login fields stay null and are still emitted."""
|
|
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 rec["last_member_login"] is None
|
|
121
|
+
assert rec["last_member_login_player_id"] is None
|
|
122
|
+
assert rec["last_member_login_game_seconds"] is None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_export_tribes_solo_active_from_profile_last_login() -> None:
|
|
126
|
+
"""A profile-only (solo) tribe gets ``active`` from the member's
|
|
127
|
+
LastLoginTime converted through the save anchor, matching legacy
|
|
128
|
+
ContentTribe.LastActive over allocated players."""
|
|
129
|
+
profile = _profile_with_last_login()
|
|
130
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
131
|
+
now = dt.datetime.now(tz=local_tz)
|
|
132
|
+
game_time = float(profile.last_login_time) + 3_600.0
|
|
133
|
+
holder = type(
|
|
134
|
+
"Holder",
|
|
135
|
+
(),
|
|
136
|
+
{"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
|
|
137
|
+
)()
|
|
138
|
+
exported = export_tribes(holder)
|
|
139
|
+
rec = next(r for r in exported if r["tribeid"] == profile.player_id)
|
|
140
|
+
assert rec["active"] is not None
|
|
141
|
+
active = dt.datetime.fromisoformat(rec["active"])
|
|
142
|
+
expected = now - dt.timedelta(seconds=3_600)
|
|
143
|
+
assert active.timestamp() == pytest.approx(expected.timestamp(), abs=2)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_export_tribes_active_never_future() -> None:
|
|
147
|
+
"""A LastLoginTime past the world's game clock is dropped on the
|
|
148
|
+
game_time rule: even with file_mtime pushed weeks into the past (so the
|
|
149
|
+
anchored candidate lands before now and the old real-domain filter would
|
|
150
|
+
keep it), the member is dropped and game_seconds never goes negative."""
|
|
151
|
+
profile = _profile_with_last_login()
|
|
152
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
153
|
+
stale_mtime = dt.datetime.now(tz=local_tz) - dt.timedelta(weeks=6)
|
|
154
|
+
game_time = max(float(profile.last_login_time) - 100_000_000.0, 1.0)
|
|
155
|
+
holder = type(
|
|
156
|
+
"Holder",
|
|
157
|
+
(),
|
|
158
|
+
{"tribes": [], "profiles": [profile], "file_mtime": stale_mtime, "game_time": game_time},
|
|
159
|
+
)()
|
|
160
|
+
exported = export_tribes(holder)
|
|
161
|
+
rec = next(r for r in exported if r["tribeid"] == profile.player_id)
|
|
162
|
+
assert rec.get("active") is None
|
|
163
|
+
assert rec["last_member_login"] is None
|
|
164
|
+
assert rec["last_member_login_game_seconds"] is None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_allocate_profiles_ignores_stale_profile_tribe_id() -> None:
|
|
168
|
+
"""A profile whose TribeID names a tribe that no longer lists the player
|
|
169
|
+
in its member roster must NOT be allocated to that tribe (the game leaves
|
|
170
|
+
TribeID stale in profiles of members removed while offline). It falls
|
|
171
|
+
through to a solo tribe keyed on the player id."""
|
|
172
|
+
from arkparser.export import _allocate_profiles
|
|
173
|
+
|
|
174
|
+
current = t.cast(Profile, type("P", (), {"player_id": 1, "raw_tribe_id": 555})())
|
|
175
|
+
stale = t.cast(Profile, type("P", (), {"player_id": 987, "raw_tribe_id": 555})())
|
|
176
|
+
member_index = {1: 555, 2: 555}
|
|
177
|
+
tribe_members = {555: {1, 2}}
|
|
178
|
+
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
179
|
+
[current, stale], {555}, member_index, tribe_members
|
|
180
|
+
)
|
|
181
|
+
assert profile_tribeid[1] == 555
|
|
182
|
+
assert profile_tribeid[987] == 987
|
|
183
|
+
assert players_by_tribe[555] == [1]
|
|
184
|
+
assert players_by_tribe[987] == [987]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_allocate_profiles_member_list_still_wins_without_explicit_id() -> None:
|
|
188
|
+
"""A profile with no usable TribeID but present in a tribe's roster is
|
|
189
|
+
still allocated to that tribe via the member-list fallback."""
|
|
190
|
+
from arkparser.export import _allocate_profiles
|
|
191
|
+
|
|
192
|
+
prof = t.cast(Profile, type("P", (), {"player_id": 2, "raw_tribe_id": 0})())
|
|
193
|
+
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
194
|
+
[prof], {555}, {2: 555}, {555: {2}}
|
|
195
|
+
)
|
|
196
|
+
assert profile_tribeid[2] == 555
|
|
197
|
+
assert players_by_tribe[555] == [2]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_export_tribe_logs_uses_tribe_parser(ase_tribe_path: Path) -> None:
|
|
201
|
+
tribe = Tribe.load(ase_tribe_path)
|
|
202
|
+
exported = export_tribe_logs(type("Holder", (), {"tribes": [tribe]})())
|
|
203
|
+
assert exported[0]["tribeid"] == tribe.tribe_id
|
|
204
|
+
assert exported[0]["tribe"] == tribe.name
|
|
205
|
+
assert exported[0]["logs"] == tribe.log_entries
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_export_all_uses_asv_names(ase_export_world_save: WorldSave) -> None:
|
|
209
|
+
exported = export_all(ase_export_world_save)
|
|
210
|
+
assert set(exported) == _ASV_NAMES
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_export_to_files_uses_asv_names(
|
|
214
|
+
ase_export_world_save: WorldSave, tmp_path: Path
|
|
215
|
+
) -> None:
|
|
216
|
+
created = export_to_files(ase_export_world_save, tmp_path)
|
|
217
|
+
assert {path.name for path in created} == {f"{name}.json" for name in _ASV_NAMES}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_profile_actives_returns_anchored_and_raw_maps() -> None:
|
|
221
|
+
"""_profile_actives yields both the anchored datetime map (legacy) and a
|
|
222
|
+
raw LastLoginTime seconds map keyed by player id."""
|
|
223
|
+
from arkparser.export import _profile_actives
|
|
224
|
+
|
|
225
|
+
profile = _profile_with_last_login()
|
|
226
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
227
|
+
now = dt.datetime.now(tz=local_tz)
|
|
228
|
+
game_time = float(profile.last_login_time) + 3_600.0
|
|
229
|
+
holder = type("Holder", (), {"file_mtime": now, "game_time": game_time})()
|
|
230
|
+
anchored, raw = _profile_actives([profile], holder)
|
|
231
|
+
pid = int(profile.player_id)
|
|
232
|
+
assert pid in anchored and pid in raw
|
|
233
|
+
assert raw[pid] == pytest.approx(float(profile.last_login_time))
|
|
234
|
+
assert isinstance(anchored[pid], dt.datetime)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_tribe_last_login_game_domain_argmax() -> None:
|
|
238
|
+
"""Winner is the max raw LastLoginTime <= game_time; all three outputs
|
|
239
|
+
describe that one member; game_seconds is a non-negative int."""
|
|
240
|
+
from arkparser.export import _tribe_last_login
|
|
241
|
+
|
|
242
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
243
|
+
t1 = dt.datetime(2024, 1, 1, tzinfo=local_tz)
|
|
244
|
+
t2 = dt.datetime(2024, 6, 1, tzinfo=local_tz)
|
|
245
|
+
anchored = {1: t1, 2: t2}
|
|
246
|
+
raw = {1: 1_000.0, 2: 5_000.5}
|
|
247
|
+
iso, pid, secs = _tribe_last_login([1, 2], anchored, raw, 9_000.0)
|
|
248
|
+
assert pid == 2
|
|
249
|
+
assert iso == t2.isoformat()
|
|
250
|
+
assert secs == 3_999 # int(9000.0 - 5000.5) floors, stays int
|
|
251
|
+
assert isinstance(secs, int) and not isinstance(secs, bool)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_tribe_last_login_drops_future_on_game_time_not_wall_clock() -> None:
|
|
255
|
+
"""A login past the world's game clock is dropped, not clamped, even when
|
|
256
|
+
its anchored datetime lands safely in the past (stale-mtime case the
|
|
257
|
+
real-domain filter gets wrong). Never emits negative game_seconds."""
|
|
258
|
+
from arkparser.export import _tribe_last_login
|
|
259
|
+
|
|
260
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
261
|
+
long_ago = dt.datetime(2020, 1, 1, tzinfo=local_tz)
|
|
262
|
+
iso, pid, secs = _tribe_last_login([7], {7: long_ago}, {7: 10_000.0}, 9_000.0)
|
|
263
|
+
assert (iso, pid, secs) == (None, None, None)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_tribe_last_login_zero_and_no_data() -> None:
|
|
267
|
+
"""Login exactly at the snapshot gives game_seconds == 0 (a real value,
|
|
268
|
+
distinct from null); empty inputs and game_time <= 0 give all-None."""
|
|
269
|
+
from arkparser.export import _tribe_last_login
|
|
270
|
+
|
|
271
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
272
|
+
stamp = dt.datetime(2024, 3, 1, tzinfo=local_tz)
|
|
273
|
+
iso, pid, secs = _tribe_last_login([3], {3: stamp}, {3: 9_000.0}, 9_000.0)
|
|
274
|
+
assert (pid, secs) == (3, 0)
|
|
275
|
+
assert iso == stamp.isoformat()
|
|
276
|
+
assert _tribe_last_login([], {}, {}, 9_000.0) == (None, None, None)
|
|
277
|
+
assert _tribe_last_login([3], {3: stamp}, {3: 9_000.0}, 0.0) == (None, None, None)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_tribe_last_login_iso_none_when_anchor_missing() -> None:
|
|
281
|
+
"""No anchored datetime (save had no usable mtime) still yields player_id
|
|
282
|
+
and game_seconds; only the ISO is None."""
|
|
283
|
+
from arkparser.export import _tribe_last_login
|
|
284
|
+
|
|
285
|
+
iso, pid, secs = _tribe_last_login([4], {}, {4: 2_500.0}, 9_000.0)
|
|
286
|
+
assert (iso, pid, secs) == (None, 4, 6_500)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_file_date_iso_past_only() -> None:
|
|
290
|
+
"""_file_date_iso emits past datetimes as ISO and drops future or
|
|
291
|
+
non-datetime values."""
|
|
292
|
+
from arkparser.export import _file_date_iso
|
|
293
|
+
|
|
294
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
295
|
+
past = dt.datetime.now(tz=local_tz) - dt.timedelta(days=1)
|
|
296
|
+
future = dt.datetime.now(tz=local_tz) + dt.timedelta(days=1)
|
|
297
|
+
assert _file_date_iso(past) == past.isoformat()
|
|
298
|
+
assert _file_date_iso(future) is None
|
|
299
|
+
assert _file_date_iso(None) is None
|
|
300
|
+
assert _file_date_iso("2024-01-01") is None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_export_tribes_last_login_ignores_world_save_mtime() -> None:
|
|
304
|
+
"""The claim the feature rests on: vary save.file_mtime across two
|
|
305
|
+
exports of the same fixture; game_seconds and player_id are identical,
|
|
306
|
+
only the anchored ISO fields (last_member_login, active) move."""
|
|
307
|
+
profile = _profile_with_last_login()
|
|
308
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
309
|
+
now = dt.datetime.now(tz=local_tz)
|
|
310
|
+
game_time = float(profile.last_login_time) + 3_600.0
|
|
311
|
+
|
|
312
|
+
def _export_with_mtime(mtime: dt.datetime) -> dict[str, t.Any]:
|
|
313
|
+
holder = type(
|
|
314
|
+
"Holder",
|
|
315
|
+
(),
|
|
316
|
+
{"tribes": [], "profiles": [profile], "file_mtime": mtime, "game_time": game_time},
|
|
317
|
+
)()
|
|
318
|
+
recs = export_tribes(holder)
|
|
319
|
+
return next(r for r in recs if r["tribeid"] == profile.player_id)
|
|
320
|
+
|
|
321
|
+
a = _export_with_mtime(now)
|
|
322
|
+
b = _export_with_mtime(now - dt.timedelta(weeks=3))
|
|
323
|
+
assert a["last_member_login_game_seconds"] == b["last_member_login_game_seconds"] == 3_600
|
|
324
|
+
assert a["last_member_login_player_id"] == b["last_member_login_player_id"] == profile.player_id
|
|
325
|
+
assert a["last_member_login"] != b["last_member_login"]
|
|
326
|
+
assert a["active"] != b["active"]
|
|
327
|
+
assert isinstance(a["last_member_login_game_seconds"], int)
|
|
328
|
+
assert not isinstance(a["last_member_login_game_seconds"], bool)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_export_tribes_solo_login_fields_match_active() -> None:
|
|
332
|
+
"""Solo/profile-only tribe: active == last_member_login (D1-b, login
|
|
333
|
+
wins), player_id names the member, game_seconds matches the offset."""
|
|
334
|
+
profile = _profile_with_last_login()
|
|
335
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
336
|
+
now = dt.datetime.now(tz=local_tz)
|
|
337
|
+
game_time = float(profile.last_login_time) + 3_600.0
|
|
338
|
+
holder = type(
|
|
339
|
+
"Holder",
|
|
340
|
+
(),
|
|
341
|
+
{"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
|
|
342
|
+
)()
|
|
343
|
+
rec = next(r for r in export_tribes(holder) if r["tribeid"] == profile.player_id)
|
|
344
|
+
assert rec["active"] == rec["last_member_login"]
|
|
345
|
+
assert rec["last_member_login_player_id"] == profile.player_id
|
|
346
|
+
assert rec["last_member_login_game_seconds"] == 3_600
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_export_tribes_all_stub_tribe_login_fields_null() -> None:
|
|
350
|
+
"""A tribe whose allocated players are all profile-less stubs yields null
|
|
351
|
+
login fields (never inherits a file date), and the keys still emit."""
|
|
352
|
+
tribe_path = _first_dump_file("*.arktribe")
|
|
353
|
+
tribe = Tribe.load(tribe_path)
|
|
354
|
+
if not tribe.get_members():
|
|
355
|
+
pytest.skip("fixture tribe has no members to stub")
|
|
356
|
+
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
357
|
+
now = dt.datetime.now(tz=local_tz)
|
|
358
|
+
holder = type(
|
|
359
|
+
"Holder",
|
|
360
|
+
(),
|
|
361
|
+
{"tribes": [tribe], "profiles": [], "file_mtime": now, "game_time": 5_000.0},
|
|
362
|
+
)()
|
|
363
|
+
rec = next(r for r in export_tribes(holder) if r["tribeid"] == tribe.tribe_id)
|
|
364
|
+
assert "last_member_login" in rec and rec["last_member_login"] is None
|
|
365
|
+
assert "last_member_login_player_id" in rec
|
|
366
|
+
assert "last_member_login_game_seconds" in rec
|
|
367
|
+
assert rec["last_member_login_game_seconds"] is None
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
"""Regression tests for export helpers against real parser objects."""
|
|
2
|
-
|
|
3
|
-
import datetime as dt
|
|
4
|
-
import os
|
|
5
|
-
import shutil
|
|
6
|
-
import typing as t
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
10
|
-
|
|
11
|
-
from arkparser import Profile, Tribe, WorldSave
|
|
12
|
-
from arkparser.export import (
|
|
13
|
-
export_all,
|
|
14
|
-
export_players,
|
|
15
|
-
export_structures,
|
|
16
|
-
export_tamed,
|
|
17
|
-
export_to_files,
|
|
18
|
-
export_tribe_logs,
|
|
19
|
-
export_tribes,
|
|
20
|
-
export_wild,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
_EXAMPLES = Path(__file__).parent.parent / "references" / "examples"
|
|
24
|
-
_ASE_SCORCHED_EARTH = _EXAMPLES / "ase" / "maps" / "scorchedearth" / "ScorchedEarth_P.ark"
|
|
25
|
-
_MAP_DUMP_EXTINCTION = Path(__file__).parent.parent / "references" / "map_dumps" / "evolved" / "extinction"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _first_dump_file(pattern: str) -> Path:
|
|
29
|
-
"""First map-dump fixture matching ``pattern``, else skip the test."""
|
|
30
|
-
if _MAP_DUMP_EXTINCTION.is_dir():
|
|
31
|
-
files = sorted(_MAP_DUMP_EXTINCTION.glob(pattern))
|
|
32
|
-
if files:
|
|
33
|
-
return files[0]
|
|
34
|
-
pytest.skip(f"no {pattern} fixture under {_MAP_DUMP_EXTINCTION}")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _profile_with_last_login() -> Profile:
|
|
38
|
-
"""First map-dump profile carrying LastLoginTime, else skip the test."""
|
|
39
|
-
if _MAP_DUMP_EXTINCTION.is_dir():
|
|
40
|
-
for path in sorted(_MAP_DUMP_EXTINCTION.glob("*.arkprofile"))[:50]:
|
|
41
|
-
try:
|
|
42
|
-
profile = Profile.load(path)
|
|
43
|
-
except Exception: # noqa: BLE001 - corrupt fixture must not abort
|
|
44
|
-
continue
|
|
45
|
-
if profile.last_login_time and profile.player_id:
|
|
46
|
-
return profile
|
|
47
|
-
pytest.skip(f"no profile with LastLoginTime under {_MAP_DUMP_EXTINCTION}")
|
|
48
|
-
|
|
49
|
-
_ASV_NAMES = {
|
|
50
|
-
"ASV_Tamed",
|
|
51
|
-
"ASV_Wild",
|
|
52
|
-
"ASV_Players",
|
|
53
|
-
"ASV_Tribes",
|
|
54
|
-
"ASV_Structures",
|
|
55
|
-
"ASV_TribeLogs",
|
|
56
|
-
"ASV_MapStructures",
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@pytest.fixture(scope="session")
|
|
61
|
-
def ase_export_world_save() -> WorldSave:
|
|
62
|
-
if not _ASE_SCORCHED_EARTH.exists():
|
|
63
|
-
pytest.skip(f"Fixture not available: {_ASE_SCORCHED_EARTH}")
|
|
64
|
-
return WorldSave.load(_ASE_SCORCHED_EARTH)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_export_tamed_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
68
|
-
exported = export_tamed(ase_export_world_save)
|
|
69
|
-
assert len(exported) == len(ase_export_world_save.get_tamed_creatures())
|
|
70
|
-
assert any(creature.get("name") == "escobar" for creature in exported)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def test_export_wild_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
74
|
-
exported = export_wild(ase_export_world_save)
|
|
75
|
-
assert len(exported) == len(ase_export_world_save.get_wild_creatures())
|
|
76
|
-
assert all(creature.get("tamer", "") == "" for creature in exported[:100])
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_export_structures_matches_world_save(ase_export_world_save: WorldSave) -> None:
|
|
80
|
-
exported = export_structures(ase_export_world_save)
|
|
81
|
-
assert len(exported) == len(ase_export_world_save.get_structures())
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_export_players_uses_profile_parser(ase_profile_path: Path) -> None:
|
|
85
|
-
profile = Profile.load(ase_profile_path)
|
|
86
|
-
exported = export_players(type("Holder", (), {"profiles": [profile]})())
|
|
87
|
-
assert exported[0]["playerid"] == profile.player_id
|
|
88
|
-
assert exported[0]["steam"] == profile.player_name
|
|
89
|
-
assert exported[0]["name"] == profile.character_name
|
|
90
|
-
assert exported[0]["sex"] == ("Female" if profile.is_female else "Male")
|
|
91
|
-
assert exported[0]["tribeid"] == profile.tribe_id
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def test_export_tribes_uses_tribe_parser(ase_tribe_path: Path) -> None:
|
|
95
|
-
tribe = Tribe.load(ase_tribe_path)
|
|
96
|
-
exported = export_tribes(type("Holder", (), {"tribes": [tribe]})())
|
|
97
|
-
assert exported[0]["tribeid"] == tribe.tribe_id
|
|
98
|
-
assert exported[0]["tribe"] == tribe.name
|
|
99
|
-
assert exported[0]["players"] == tribe.member_count
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def test_export_tribes_active_is_file_mtime(tmp_path: Path) -> None:
|
|
103
|
-
"""Tribe ``active`` mirrors legacy ContentTribe.LastActive: the .arktribe
|
|
104
|
-
file's write time, never a value derived from in-game log day numbers
|
|
105
|
-
(those use the game calendar, not real seconds, and produced far-future
|
|
106
|
-
dates)."""
|
|
107
|
-
tribe_path = _first_dump_file("*.arktribe")
|
|
108
|
-
copy = tmp_path / tribe_path.name
|
|
109
|
-
shutil.copy2(tribe_path, copy)
|
|
110
|
-
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
111
|
-
stamp = dt.datetime(2024, 5, 1, 12, 0, 0, tzinfo=local_tz)
|
|
112
|
-
os.utime(copy, (stamp.timestamp(), stamp.timestamp()))
|
|
113
|
-
tribe = Tribe.load(copy)
|
|
114
|
-
now = dt.datetime.now(tz=local_tz)
|
|
115
|
-
holder = type("Holder", (), {"tribes": [tribe], "file_mtime": now, "game_time": 5_000.0})()
|
|
116
|
-
exported = export_tribes(holder)
|
|
117
|
-
rec = next(r for r in exported if r["tribeid"] == tribe.tribe_id)
|
|
118
|
-
assert rec["active"] is not None
|
|
119
|
-
active = dt.datetime.fromisoformat(rec["active"])
|
|
120
|
-
assert active.timestamp() == pytest.approx(stamp.timestamp(), abs=2)
|
|
121
|
-
assert active <= now
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_export_tribes_solo_active_from_profile_last_login() -> None:
|
|
125
|
-
"""A profile-only (solo) tribe gets ``active`` from the member's
|
|
126
|
-
LastLoginTime converted through the save anchor, matching legacy
|
|
127
|
-
ContentTribe.LastActive over allocated players."""
|
|
128
|
-
profile = _profile_with_last_login()
|
|
129
|
-
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
130
|
-
now = dt.datetime.now(tz=local_tz)
|
|
131
|
-
game_time = float(profile.last_login_time) + 3_600.0
|
|
132
|
-
holder = type(
|
|
133
|
-
"Holder",
|
|
134
|
-
(),
|
|
135
|
-
{"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
|
|
136
|
-
)()
|
|
137
|
-
exported = export_tribes(holder)
|
|
138
|
-
rec = next(r for r in exported if r["tribeid"] == profile.player_id)
|
|
139
|
-
assert rec["active"] is not None
|
|
140
|
-
active = dt.datetime.fromisoformat(rec["active"])
|
|
141
|
-
expected = now - dt.timedelta(seconds=3_600)
|
|
142
|
-
assert active.timestamp() == pytest.approx(expected.timestamp(), abs=2)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def test_export_tribes_active_never_future() -> None:
|
|
146
|
-
"""Future candidates are discarded (legacy filters d <= DateTime.Now)."""
|
|
147
|
-
profile = _profile_with_last_login()
|
|
148
|
-
local_tz = dt.datetime.now().astimezone().tzinfo
|
|
149
|
-
now = dt.datetime.now(tz=local_tz)
|
|
150
|
-
game_time = max(float(profile.last_login_time) - 100_000_000.0, 1.0)
|
|
151
|
-
holder = type(
|
|
152
|
-
"Holder",
|
|
153
|
-
(),
|
|
154
|
-
{"tribes": [], "profiles": [profile], "file_mtime": now, "game_time": game_time},
|
|
155
|
-
)()
|
|
156
|
-
exported = export_tribes(holder)
|
|
157
|
-
rec = next(r for r in exported if r["tribeid"] == profile.player_id)
|
|
158
|
-
assert rec["active"] is None
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def test_allocate_profiles_ignores_stale_profile_tribe_id() -> None:
|
|
162
|
-
"""A profile whose TribeID names a tribe that no longer lists the player
|
|
163
|
-
in its member roster must NOT be allocated to that tribe (the game leaves
|
|
164
|
-
TribeID stale in profiles of members removed while offline). It falls
|
|
165
|
-
through to a solo tribe keyed on the player id."""
|
|
166
|
-
from arkparser.export import _allocate_profiles
|
|
167
|
-
|
|
168
|
-
current = t.cast(Profile, type("P", (), {"player_id": 1, "raw_tribe_id": 555})())
|
|
169
|
-
stale = t.cast(Profile, type("P", (), {"player_id": 987, "raw_tribe_id": 555})())
|
|
170
|
-
member_index = {1: 555, 2: 555}
|
|
171
|
-
tribe_members = {555: {1, 2}}
|
|
172
|
-
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
173
|
-
[current, stale], {555}, member_index, tribe_members
|
|
174
|
-
)
|
|
175
|
-
assert profile_tribeid[1] == 555
|
|
176
|
-
assert profile_tribeid[987] == 987
|
|
177
|
-
assert players_by_tribe[555] == [1]
|
|
178
|
-
assert players_by_tribe[987] == [987]
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def test_allocate_profiles_member_list_still_wins_without_explicit_id() -> None:
|
|
182
|
-
"""A profile with no usable TribeID but present in a tribe's roster is
|
|
183
|
-
still allocated to that tribe via the member-list fallback."""
|
|
184
|
-
from arkparser.export import _allocate_profiles
|
|
185
|
-
|
|
186
|
-
prof = t.cast(Profile, type("P", (), {"player_id": 2, "raw_tribe_id": 0})())
|
|
187
|
-
profile_tribeid, players_by_tribe = _allocate_profiles(
|
|
188
|
-
[prof], {555}, {2: 555}, {555: {2}}
|
|
189
|
-
)
|
|
190
|
-
assert profile_tribeid[2] == 555
|
|
191
|
-
assert players_by_tribe[555] == [2]
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def test_export_tribe_logs_uses_tribe_parser(ase_tribe_path: Path) -> None:
|
|
195
|
-
tribe = Tribe.load(ase_tribe_path)
|
|
196
|
-
exported = export_tribe_logs(type("Holder", (), {"tribes": [tribe]})())
|
|
197
|
-
assert exported[0]["tribeid"] == tribe.tribe_id
|
|
198
|
-
assert exported[0]["tribe"] == tribe.name
|
|
199
|
-
assert exported[0]["logs"] == tribe.log_entries
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def test_export_all_uses_asv_names(ase_export_world_save: WorldSave) -> None:
|
|
203
|
-
exported = export_all(ase_export_world_save)
|
|
204
|
-
assert set(exported) == _ASV_NAMES
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def test_export_to_files_uses_asv_names(
|
|
208
|
-
ase_export_world_save: WorldSave, tmp_path: Path
|
|
209
|
-
) -> None:
|
|
210
|
-
created = export_to_files(ase_export_world_save, tmp_path)
|
|
211
|
-
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
|
|
File without changes
|
|
File without changes
|