Skip to content

Commit

Permalink
Add replay semantics that are used by the client (#151)
Browse files Browse the repository at this point in the history
* Implement some of the semantics that the client implemented manually

* Add tests

* Add the check for invalid characters

* Improve documentation

* Simplify the parameters of retrieving the chat messages
  • Loading branch information
Garanas authored Jun 6, 2024
1 parent 5a6ff42 commit a0b1edf
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
/**
* A container of all the information that a replay may hold once parsed.
*
* @param metadata
* @param header
* @param registeredEvents
* @param metadata Metadata that is attached to the FAF replay format.
* @param header The header of the replay that represents the scenario.
* @param registeredEvents The events of the replay that represent the input of players.
*/
public record ReplayContainer(ReplayMetadata metadata, ReplayHeader header, List<RegisteredEvent> registeredEvents) {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.faforever.commons.replay;

import com.faforever.commons.replay.body.Event;
import com.faforever.commons.replay.header.GameMod;
import com.faforever.commons.replay.header.Source;
import com.faforever.commons.replay.shared.LuaData;

Expand All @@ -9,15 +10,26 @@
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ReplaySemantics {

private static final Pattern invalidCharacters = Pattern.compile("[?@*%{}<>|\"]");

/**
* Small utility function to convert a tick to the equivalent game time.
*
* @param tick
* @return
*/
public static Duration tickToDuration(int tick) {
return Duration.ofSeconds(tick / 10);
}

/**
* Registers the events by attaching a tick and a source to them.
*
* @param sources All input sources of the replay
* @param events All events of the replay
* @return All events with the tick and input source attached
Expand All @@ -42,12 +54,44 @@ public static List<RegisteredEvent> registerEvents(List<Source> sources, List<Ev
}

/**
* Retrieves all events that are chat messages
* Retrieves the UID of all mods.
*
* @param replayContainer The replay to retrieve the UIDs from
* @return A list of UIDs
*/
public static List<String> getModUIDs(ReplayContainer replayContainer) {
return replayContainer.header().mods().stream().map(GameMod::uid).toList();
}

/**
* Retrieves the directory that contains the scenario file.
*
* @param replayContainer The replay to retrieve the folder from
* @return The directory that contains the scenario file
*/
public static String getMapFolder (ReplayContainer replayContainer) throws IllegalArgumentException {
// /maps/SCMP_026/SCMP_026_script.lua
String pathToScenario = replayContainer.header().pathToScenario();
if (pathToScenario == null) {
return null;
}

Matcher matcher = invalidCharacters.matcher(pathToScenario);
if (matcher.find()) {
throw new IllegalArgumentException();
}

// SCMP_026
return pathToScenario.split("/")[2];
}

/**
* Retrieves all events that are chat messages.
*
* @param events A list of events
* @return A list of events that are chat messages
*/
public static List<ChatMessage> findChatMessages(List<Source> sources, List<RegisteredEvent> events) {
public static List<ChatMessage> getChatMessages(List<RegisteredEvent> events) {
return events.stream().map((registeredEvent) -> switch (registeredEvent.event()) {

// TODO: the fact that we piggy-back on the 'GiveResourcesToPlayer' callback to embed chat messages is all wrong! We should instead introduce an alternative callback with the sole purpose to send messages.
Expand Down Expand Up @@ -100,12 +144,12 @@ public static List<ChatMessage> findChatMessages(List<Source> sources, List<Regi
}

/**
* Retrieves all events that are moderator related
* Retrieves all events that are related to moderation.
*
* @param events A list of events
* @return A list of events that are moderator related
* @return A list of events that are related to moderation
*/
public static List<ModeratorEvent> findModeratorMessages(List<Source> sources, List<RegisteredEvent> events) {
public static List<ModeratorEvent> getModeratorMessages(List<Source> sources, List<RegisteredEvent> events) {
return events.stream().map((registeredEvent) -> switch (registeredEvent.event()) {

// TODO: also read other interesting events, such as:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ public record ReplayBodyToken(TokenId tokenId, int tokenSize, byte[] tokenConten

public enum TokenId {
// Order is crucial
CMDST_ADVANCE,
CMDST_SET_COMMAND_SOURCE,
CMDST_ADVANCE, // advances the tick for the next tokens in the stream
CMDST_SET_COMMAND_SOURCE, // sets the command source for the next tokens in the stream
CMDST_COMMAND_SOURCE_TERMINATED,
CMDST_VERIFY_CHECKSUM,
CMDST_REQUEST_PAUSE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,28 @@

import java.util.Map;

public record GameScenario(String mapPath, Integer mapVersion, String mapDescription, String mapScript, String mapSave,
String mapName, Integer mapSizeX, Integer mapSizeZ, Integer reclaimMassValue,
Integer reclaimEnergyValue, GameOptions gameOptions,
Map<String, String> modOptions) {
/**
* @param mapScenarioPath "/maps/scmp_026/scmp_026_scenario.lua" , as defined in the game options
* @param mapBinaryPath "/maps/SCMP_026/SCMP_026.scmap" , as defined by the `map` field in `_scenario.lua`
* @param mapVersion Can be null for maps with no versioning (gpg maps)
* @param mapDescription Description of the map
* @param mapScriptPath "/maps/SCMP_026/SCMP_026_script.lua", as defined by the `script` field in `_scenario.lua`
* @param mapSavePath "/maps/SCMP_026/SCMP_026_save.lua", as defined by the `save` field in `_scenario.lua`
* @param mapName Name of the map
* @param mapSizeX Width of the (height)map, does not take into account reductions of playable area via map script
* @param mapSizeZ Height of the (height)map, does not take into account reductions of playable area via map script
* @param reclaimMassValue Total initial mass reclaim value of props, does not take into account wrecks
* @param reclaimEnergyValue Total initial energy reclaim value of props
* @param gameOptions Standard game options
* @param modOptions Options that originate from mods
*/
public record GameScenario(
String mapScenarioPath,
String mapBinaryPath,
Integer mapVersion,
String mapDescription,
String mapScriptPath, String mapSavePath,
String mapName, Integer mapSizeX, Integer mapSizeZ, Integer reclaimMassValue,
Integer reclaimEnergyValue, GameOptions gameOptions,
Map<String, String> modOptions) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,12 @@ public static ReplayHeader parse(LittleEndianDataInputStream dataStream) throws
if (gameScenario instanceof LuaData.Table table) {

// retrieve and manage the game options
String scenarioFile = null;
GameOptions gameOptions = null;
Map<String, String> modOptions = null;
if (table.value().get("Options") instanceof LuaData.Table optionsTable) {

scenarioFile = optionsTable.getString("ScenarioFile");
gameOptions = new GameOptions(
GameOptions.AutoTeams.findByKey(optionsTable.getString("AutoTeams")),
GameOptions.TeamLock.findByKey(optionsTable.getString("TeamLock")),
Expand Down Expand Up @@ -151,6 +153,7 @@ public static ReplayHeader parse(LittleEndianDataInputStream dataStream) throws
}

return new GameScenario(
scenarioFile,
table.getString("map"),
table.getInteger("map_version"),
table.getString("description"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;

class LoadReplayLoaderTest {

Expand All @@ -41,8 +43,9 @@ public void parseBinary01() throws CompressorException, IOException {

assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("neroxis_map_generator_1.11.0_wvmzfgnlgiebu_bqgaeb3bgzldwstbaa", ReplaySemantics.getMapFolder(fafReplayContainer));

List<ChatMessage> chatMessages = ReplaySemantics.findChatMessages(fafReplayContainer.header().sources(), fafReplayContainer.registeredEvents());
List<ChatMessage> chatMessages = ReplaySemantics.getChatMessages(fafReplayContainer.registeredEvents());
assertEquals(106, chatMessages.size());
}
);
Expand All @@ -58,8 +61,9 @@ public void parseBinary02() throws CompressorException, IOException {

assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("SCMP_039", ReplaySemantics.getMapFolder(fafReplayContainer));

List<ChatMessage> chatMessages = ReplaySemantics.findChatMessages(fafReplayContainer.header().sources(), fafReplayContainer.registeredEvents());
List<ChatMessage> chatMessages = ReplaySemantics.getChatMessages(fafReplayContainer.registeredEvents());
assertEquals(2, chatMessages.size());
}
);
Expand All @@ -75,8 +79,9 @@ public void parseBinary03() throws CompressorException, IOException {

assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("dualgap_fix_adaptive.v0007", ReplaySemantics.getMapFolder(fafReplayContainer));

List<ChatMessage> chatMessages = ReplaySemantics.findChatMessages(fafReplayContainer.header().sources(), fafReplayContainer.registeredEvents());
List<ChatMessage> chatMessages = ReplaySemantics.getChatMessages(fafReplayContainer.registeredEvents());
assertEquals(1, chatMessages.size());
}
);
Expand All @@ -92,8 +97,9 @@ public void parseBinary04() throws CompressorException, IOException {

assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("SCMP_009", ReplaySemantics.getMapFolder(fafReplayContainer));

List<ChatMessage> chatMessages = ReplaySemantics.findChatMessages(fafReplayContainer.header().sources(), fafReplayContainer.registeredEvents());
List<ChatMessage> chatMessages = ReplaySemantics.getChatMessages(fafReplayContainer.registeredEvents());
assertEquals(7, chatMessages.size());
}
);
Expand All @@ -109,8 +115,9 @@ public void parseBinary05() throws CompressorException, IOException {

assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("SCMP_026", ReplaySemantics.getMapFolder(fafReplayContainer));

List<ChatMessage> chatMessages = ReplaySemantics.findChatMessages(fafReplayContainer.header().sources(), fafReplayContainer.registeredEvents());
List<ChatMessage> chatMessages = ReplaySemantics.getChatMessages(fafReplayContainer.registeredEvents());
assertEquals(0, chatMessages.size());
}
);
Expand All @@ -126,8 +133,9 @@ public void parseBinary06() throws CompressorException, IOException {

assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("SCMP_026", ReplaySemantics.getMapFolder(fafReplayContainer));

List<ChatMessage> chatMessages = ReplaySemantics.findChatMessages(fafReplayContainer.header().sources(), fafReplayContainer.registeredEvents());
List<ChatMessage> chatMessages = ReplaySemantics.getChatMessages(fafReplayContainer.registeredEvents());
assertEquals(0, chatMessages.size());
}
);
Expand All @@ -143,8 +151,9 @@ public void parseBinary07() throws CompressorException, IOException {

assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("SCMP_026", ReplaySemantics.getMapFolder(fafReplayContainer));

List<ChatMessage> chatMessages = ReplaySemantics.findChatMessages(fafReplayContainer.header().sources(), fafReplayContainer.registeredEvents());
List<ChatMessage> chatMessages = ReplaySemantics.getChatMessages(fafReplayContainer.registeredEvents());
assertEquals(3, chatMessages.size());
}
);
Expand All @@ -160,10 +169,16 @@ public void parseBinary08() throws CompressorException, IOException {

assertEquals(2, fafReplayContainer.header().mods().size());

List<String> modUIDs = new ArrayList<>();
modUIDs.add("fnewm028-v096-55b4-92b6-64398e7ge43f");
modUIDs.add("d883189d-c556-4d68-b1c8-6ad201b3f7ad");
assertLinesMatch(modUIDs , ReplaySemantics.getModUIDs(fafReplayContainer));

assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("project_tumulus.v0004", ReplaySemantics.getMapFolder(fafReplayContainer));

List<ChatMessage> chatMessages = ReplaySemantics.findChatMessages(fafReplayContainer.header().sources(), fafReplayContainer.registeredEvents());
List<ChatMessage> chatMessages = ReplaySemantics.getChatMessages(fafReplayContainer.registeredEvents());
assertEquals(0, chatMessages.size());
}
);
Expand All @@ -180,10 +195,12 @@ public void compareBinary01() throws CompressorException, IOException {
ReplayContainer fafReplayContainer = ReplayLoader.loadFAFReplayFromDisk(fafReplayFile);
assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("SCMP_026", ReplaySemantics.getMapFolder(fafReplayContainer));

ReplayContainer scfaReplayContainer = ReplayLoader.loadSCFAReplayFromDisk(scfaReplayFile);
assertNoUnprocessedTokens(scfaReplayContainer);
assertNoErrorTokens(scfaReplayContainer);
assertEquals("SCMP_026", ReplaySemantics.getMapFolder(fafReplayContainer));

assertEquals(scfaReplayContainer.registeredEvents().size(), fafReplayContainer.registeredEvents().size());
assertArrayEquals( scfaReplayContainer.registeredEvents().toArray(), fafReplayContainer.registeredEvents().toArray());
Expand All @@ -200,10 +217,12 @@ public void compareBinary02() throws CompressorException, IOException {
ReplayContainer fafReplayContainer = ReplayLoader.loadFAFReplayFromDisk(fafReplayFile);
assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("open_palms_-_faf_version.v0002", ReplaySemantics.getMapFolder(fafReplayContainer));

ReplayContainer scfaReplayContainer = ReplayLoader.loadSCFAReplayFromDisk(scfaReplayFile);
assertNoUnprocessedTokens(scfaReplayContainer);
assertNoErrorTokens(scfaReplayContainer);
assertEquals("open_palms_-_faf_version.v0002", ReplaySemantics.getMapFolder(fafReplayContainer));

assertEquals(scfaReplayContainer.registeredEvents().size(), fafReplayContainer.registeredEvents().size());
assertArrayEquals( scfaReplayContainer.registeredEvents().toArray(), fafReplayContainer.registeredEvents().toArray());
Expand All @@ -221,10 +240,12 @@ public void compareBinary03() throws CompressorException, IOException {
ReplayContainer fafReplayContainer = ReplayLoader.loadFAFReplayFromDisk(fafReplayFile);
assertNoUnprocessedTokens(fafReplayContainer);
assertNoErrorTokens(fafReplayContainer);
assertEquals("SCMP_026", ReplaySemantics.getMapFolder(fafReplayContainer));

ReplayContainer scfaReplayContainer = ReplayLoader.loadSCFAReplayFromDisk(scfaReplayFile);
assertNoUnprocessedTokens(scfaReplayContainer);
assertNoErrorTokens(scfaReplayContainer);
assertEquals("SCMP_026", ReplaySemantics.getMapFolder(fafReplayContainer));

assertEquals(scfaReplayContainer.registeredEvents().size(), fafReplayContainer.registeredEvents().size());
assertArrayEquals( scfaReplayContainer.registeredEvents().toArray(), fafReplayContainer.registeredEvents().toArray());
Expand Down

0 comments on commit a0b1edf

Please sign in to comment.