Skip to main content

Replay Analysis

ReplayInterface lets you load a .conrp replay file and inspect the full game state at any point in time. It exposes the same query methods as OnlineInterface — get_armies(), get_provinces(), get_players(), etc. — but instead of talking to a live server, it reconstructs state by applying recorded patches.

What You Need​

  • A .conrp replay file (produced by the Conlyse ServerConverter pipeline)
  • A .bin static map data file for the map used in that game

If you don't know which map IDs a replay uses, open it in read_metadata mode first (see below).

Opening a Replay​

Full mode​

from conflict_interface.interface.replay_interface import ReplayInterface
from pathlib import Path

replay = ReplayInterface(
Path("game_12345.conrp"),
static_map_data={"5652_28": Path("5652_28.bin")},
)
replay.open()

# ... work with the replay ...

replay.close()

The static_map_data argument maps each map_id string to the path of its .bin file. open() raises ValueError if a required map ID has no corresponding file.

Metadata-only mode​

Use this to inspect a replay's metadata without loading the full game state — useful for discovering which map files you need:

replay = ReplayInterface(Path("game_12345.conrp"))
replay.open(mode='read_metadata')

print(replay.get_required_map_ids()) # e.g. {"5652_28"}
meta = replay.get_timeline_metadata()
print(meta.game_id, meta.day_of_game, meta.game_ended)

replay.close()

Always call replay.close() when finished. Use a try/finally block to guarantee cleanup:

try:
replay.open()
# ...
finally:
replay.close()

Replay Metadata​

Before navigating, you can inspect what the replay contains:

# Timeline-level (whole file)
meta = replay.get_timeline_metadata()
# Fields: game_id, player_id, scenario_id, day_of_game, game_ended,
# start_of_game, end_of_game, speed, segment_count

# Per-segment info
segs = replay.get_segments_metadata()
for seg_id, seg_meta in segs.items():
print(seg_meta.version, seg_meta.map_id, seg_meta.current_patches)

print(f"Total patches: {replay.get_total_patches()}")
print(f"Map versions used: {replay.get_required_versions()}")

After open(), the replay is positioned at the start. All navigation calls update replay.current_time and rebuild replay.game_state.

Timestamps​

timestamps = replay.get_timestamps()   # list[datetime], sorted ascending
print(replay.start_time) # datetime — first timestamp
print(replay.last_time) # datetime — last timestamp
print(replay.current_time) # datetime — current position

Jumping to a specific time​

replay.jump_to(timestamps[50])         # arbitrary timestamp
replay.jump_to(replay.last_time) # shortcut: go to end
replay.jump_to_last_time() # same as above

jump_to() uses Dijkstra over the patch graph and automatically creates consolidated "long patches" for large jumps to keep performance consistent.

Step-by-step traversal​

jump_to_next_patch() and jump_to_previous_patch() are O(1) and ideal for iterating through the entire replay:

while replay.jump_to_next_patch():
# replay.game_state and current_time are updated
pass

Both methods return False when the boundary (start or end) is reached.

Peeking at adjacent timestamps​

nxt  = replay.get_next_timestamp()     # datetime | None — doesn't move
prev = replay.get_previous_timestamp() # datetime | None — doesn't move

Querying State​

After any jump_to* call, all GameInterface query methods work identically to live games:

replay.jump_to(timestamps[100])

# Provinces
provs = replay.get_provinces()
city = replay.get_province_by_name("Berlin")

# Armies
armies = replay.get_armies()
my_armies = replay.get_my_armies() # filtered by replay's player_id

# Players
players = replay.get_players()
me = replay.get_my_player()

# Game info
info = replay.get_game_info_state()
print(info.day_of_game)

# Resources, research, foreign affairs, etc.
amounts = replay.get_my_resource_amounts()

Direct access to the raw state tree is also available:

state = replay.game_state.states
provinces = state.map_state.map.provinces
armies = state.army_state.armies

Event / Hook System​

Rather than polling every field after each step, you can register hooks that fire automatically when specific attributes change. This is the efficient way to build analysis pipelines.

Trigger-based hooks (poll after each step)​

Register a trigger once, then step through the replay and poll accumulated events:

from conflict_interface.hook_system.replay_hook_tag import ReplayHookTag

# Watch specific attributes
replay.register_province_trigger(attributes=["owner_id", "morale"])
replay.register_player_trigger(attributes=["defeated"])
replay.register_army_trigger() # watch all army attributes
replay.register_game_info_trigger(attributes=["day_of_game"])

while replay.jump_to_next_patch():
events = replay.poll_events() # dict[ReplayHookTag, list[ReplayHookEvent]]

for event in events.get(ReplayHookTag.ProvinceChanged, []):
province = event.reference # LandProvince object
changes = event.attributes # {"owner_id": (old, new), ...}
if "owner_id" in changes:
old, new = changes["owner_id"]
print(f"Day {replay.game_day()}: {province.name} flipped {old} → {new}")

for event in events.get(ReplayHookTag.PlayerChanged, []):
player = event.reference
if "defeated" in event.attributes:
print(f"{player.nation_name} was eliminated")

poll_events() clears the queue — call it once per step.

Callback-style hooks (fire inline)​

For code that should run immediately when a change occurs:

def on_province_captured(province, changes):
old_owner, new_owner = changes["owner_id"]
print(f"{province.name}: {old_owner} → {new_owner}")

replay.on_province_attribute_change(
callback=on_province_captured,
attributes=["owner_id"],
)

# Callbacks fire automatically during each jump
replay.jump_to(replay.last_time)

# Remove when done
replay.remove_province_attribute_change_callback(on_province_captured)

ReplayHookEvent fields​

FieldTypeDescription
tagReplayHookTagWhich category changed
referenceobjectThe changed object (e.g. LandProvince, Army)
attributesdict[str, tuple]{"field": (old_value, new_value)} for each changed attribute

ReplayHookTag values​

TagFired when
ProvinceChangedA province attribute changed
PlayerChangedA player attribute changed
TeamChangedA team attribute changed
ArmyChangedAn army attribute changed
GameInfoChangedA game-info attribute changed (e.g. day advanced)
SegmentSwitchPlayback crossed into a new replay segment

Cleanup​

replay.unregister_province_trigger()
replay.unregister_army_trigger()
replay.unregister_player_trigger()
replay.unregister_team_trigger()
replay.unregister_game_info_trigger()

# Or remove everything at once:
replay.unregister_all_hooks()

Full Example: Ownership History​

from conflict_interface.interface.replay_interface import ReplayInterface
from conflict_interface.hook_system.replay_hook_tag import ReplayHookTag
from pathlib import Path
from collections import defaultdict

replay = ReplayInterface(
Path("game_12345.conrp"),
static_map_data={"5652_28": Path("5652_28.bin")},
)
replay.open()
replay.register_province_trigger(attributes=["owner_id"])

ownership_changes = defaultdict(list)

while replay.jump_to_next_patch():
events = replay.poll_events()
for event in events.get(ReplayHookTag.ProvinceChanged, []):
if "owner_id" in event.attributes:
province = event.reference
old, new = event.attributes["owner_id"]
ownership_changes[province.name].append({
"time": replay.current_time,
"day": replay.game_day(),
"from": old,
"to": new,
})

replay.close()

for name, changes in ownership_changes.items():
print(f"\n{name}: {len(changes)} ownership change(s)")
for c in changes:
print(f" Day {c['day']}: player {c['from']} → {c['to']}")