-
Notifications
You must be signed in to change notification settings - Fork 10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Rework of parsing the header of a replay #143
Conversation
faf-commons-data/src/test/java/com/faforever/commons/replay/LoadReplayLoaderTest.java
Outdated
Show resolved
Hide resolved
@Sheikah45 How would I verify various properties such as the token length being equal to the event length when those properties are not exposed? |
I would ask if the bytes aren't exposed why do they need to be verified by a test at all? Wouldn't it be equivalent to just check the events directly? And in a test you know the tokens should be equal to the events so you just need to assert the number of events is as expected for a replay file. Also as a side note, I don't think the records that wrap a single class are necessary, unless you have plans to extend them later on but from what I can see that doesn't seem to be the case. |
Are you referring to a record such as |
I meant ReplayBody sorry for not clarifying |
Also as a side note we don't generally use annotations such as @ Contract since the upkeep of those isn't necessarily worth the benefit from the ide analysis for our repos |
faf-commons-data/src/main/java/com/faforever/commons/replay/shared/LuaTable.java
Outdated
Show resolved
Hide resolved
I understand, it's primarily to make it conceptually clear. I'll remove it again |
To verify everything is correct from the bottom up. Instead, I've introduced conditions that throw an exception when things are off during parsing. That also ironically forced me to fix two bugs, so that's nice 😃 |
I'd like to add some more replays (5 from co-op, 5 from faf, 5 from fafdevelop, etc) to more solidify the current setup. I'll also test other properties such as the number of events of a given type, to catch regression if more changes are made in the future. Would that test the feature sufficient? |
Why do we need 5 of each replay? That seems like a bit much given they don't contain very different structures |
I would normally think even just one test for parsing is sufficient given it is not a stochastic process and they don't contain unknown events |
int sizeGameOptionsInBytes = dataStream.readInt(); | ||
this.gameOptions = ((Map<String, Object>) parseLua(dataStream)).entrySet().stream().filter(entry -> "Options".equals(entry.getKey())).flatMap(entry -> ((Map<String, Object>) entry.getValue()).entrySet().stream()).map(entry -> new GameOption(entry.getKey(), entry.getValue())).collect(Collectors.toList()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
leave this wrapped as it is more readable
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's the formatter!! 😛
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Processed in:
public enum AutoTeams { | ||
none("None"), | ||
manual("Manual"), | ||
tvsb("Top versus bottom"), | ||
lvsr("Left versus right"), | ||
pvsi("Even versus uneven"); | ||
|
||
private final String string; | ||
|
||
AutoTeams(String string) { | ||
this.string = string; | ||
} | ||
} | ||
|
||
public enum TeamLock { | ||
locked("Locked"), unlocked("Unlocked"); | ||
|
||
private final String string; | ||
|
||
TeamLock(String string) { | ||
this.string = string; | ||
} | ||
} | ||
|
||
public enum TeamSpawn { | ||
fixed("Fixed"), | ||
random("Random"), | ||
balanced("Balanced"), | ||
balanced_flex("Flexible balanced"), | ||
random_reveal("Random and revealed"), | ||
balanced_reveal("Balanced and revealed"), | ||
balanced_reveal_mirrored("Mirror balanced and revealed"), | ||
balanced_flex_reveal("Flexible balanced and revealed"); | ||
|
||
private final String string; | ||
|
||
TeamSpawn(String string) { | ||
this.string = string; | ||
} | ||
} | ||
|
||
public enum Victory { | ||
demoralization("Assasination"), | ||
domination("Supremacy"), | ||
eradication("Annihilation"), | ||
sandbox("Sandbox"); | ||
|
||
private final String string; | ||
|
||
Victory(String string) { | ||
this.string = string; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Java enums should be SCREAMING_SNAKE_CASE and AutoTeams can be the full name for the enum
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also the strings don't have getters so aren't accessible outside the enum
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would I then match the game options with the enums? At the moment they are demoralization
, domination
, etc which I think I can immediate map one-to-one to an enum.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You would add a findByValue method that takes in a string and returns the enum that matches the string you define as a member of the enum.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Noting that the enum names should be normalized
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Processed in cce2ee3
Not all replays have all information in them however. I think I've covered almost all of the cases at this moment |
With d0e9566 we introduce the first semantics: applying the tick and the client to an event! It also casually shrinks the number of events of a large replay from ~130k to ~15k, which makes it much more feasible to process too 😃 |
final int[] tick = {-1}; | ||
final int[] clientId = {-1}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would just use an AtomicInteger here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Processed in 0f0aca2
case Event.LuaSimCallback( | ||
String func, LuaTable parametersLua, Event.CommandUnits commandUnits | ||
) when func.equals("GiveResourcesToPlayer") -> { | ||
yield switch (parametersLua) { | ||
case LuaTable.Table callbackTable -> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't need the internal switch. If you type parametersLua as LuaTable.Table the case will only match when that is the case
case Event.LuaSimCallback( | |
String func, LuaTable parametersLua, Event.CommandUnits commandUnits | |
) when func.equals("GiveResourcesToPlayer") -> { | |
yield switch (parametersLua) { | |
case LuaTable.Table callbackTable -> { | |
case Event.LuaSimCallback( | |
String func, LuaTable.Table callbackTable, Event.CommandUnits commandUnits | |
) when func.equals("GiveResourcesToPlayer") -> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And since this is only one case label we can just use an if (!( instanceof))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I removed the internal switch in 7d4ef66
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know how to rewrite the instance check and also keep the when
statement, or are those two separate if statements?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You would just change it from when to a normal && condition
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd rather keep the current format. It makes it consistent with the other semantic-related functions. If you want me to change it anyway then I'll do that; but I think consistency is nice.
This pull request is ready for feedback - functionally it is where I'd want it to be and further semantics can be introduced as separate pull requests. I'll only focus on extending the test suite and processing the feedback. |
if(stream.available() > 0) { | ||
throw new EOFException(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This a little confusing use of exception. typically EOFException means we hit the end of the file when we are expecting more data. Here we throw an exception if there is data left over.
I think probably just an illegal state exception with appropriate message is best
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree it is semantically better, I was searching for a proper exception when writing the code. Processed it in:
public enum AutoTeams { | ||
none("None"), | ||
manual("Manual"), | ||
tvsb("Top versus bottom"), | ||
lvsr("Left versus right"), | ||
pvsi("Even versus uneven"); | ||
|
||
private final String string; | ||
|
||
AutoTeams(String string) { | ||
this.string = string; | ||
} | ||
} | ||
|
||
public enum TeamLock { | ||
locked("Locked"), unlocked("Unlocked"); | ||
|
||
private final String string; | ||
|
||
TeamLock(String string) { | ||
this.string = string; | ||
} | ||
} | ||
|
||
public enum TeamSpawn { | ||
fixed("Fixed"), | ||
random("Random"), | ||
balanced("Balanced"), | ||
balanced_flex("Flexible balanced"), | ||
random_reveal("Random and revealed"), | ||
balanced_reveal("Balanced and revealed"), | ||
balanced_reveal_mirrored("Mirror balanced and revealed"), | ||
balanced_flex_reveal("Flexible balanced and revealed"); | ||
|
||
private final String string; | ||
|
||
TeamSpawn(String string) { | ||
this.string = string; | ||
} | ||
} | ||
|
||
public enum Victory { | ||
demoralization("Assasination"), | ||
domination("Supremacy"), | ||
eradication("Annihilation"), | ||
sandbox("Sandbox"); | ||
|
||
private final String string; | ||
|
||
Victory(String string) { | ||
this.string = string; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Noting that the enum names should be normalized
|
||
|
||
|
||
|
||
|
||
return null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why all the whitespace? And why return null instead of an empty list?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same whitespace question
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See the previous response
|
||
|
||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same whitespace question
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See the previous response
public record ReplayHeaderToken(String gameVersion, String replayVersion, String pathToScenario, boolean cheatsEnabled, int seed, | ||
List<Source> sources, | ||
byte[] mods, | ||
byte[] gameOptions, | ||
List<byte[]> playerOptions) { | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did you decide to have the header be represented by this more raw value and a more complete value in ReplayHeader? I don't know if there is the same value to having both of these that there is for the body tokens.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was a little bit at a loss here. The header is not as nicely structured as the body of a replay. I did find a few cases where the header has a leading 'size' integer and I interpret those as 'I'll process whatever is in there during parsing'
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it is probably better to just parse all the fields directly rather than having two objects since there isn't exactly the same issue with new tokens and the size notation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That would forfeit the separation of phases; should I then just process/parse everything in the ReplayHeaderParser
class?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I would think so. Since it seems like the header is really more like a single set of bytes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Processed in 76615a8
* @param name | ||
* @param sourceId | ||
*/ | ||
public record Source(int sourceId, int PlayerId, String name) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
playerId with proper casing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Processed in:
import java.util.HashMap; | ||
import java.util.Map; | ||
|
||
public class Utils { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would probably rename to LoadUtils or ParseUtils
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Processed in 020955d
final AtomicInteger tick = new AtomicInteger(0); | ||
final AtomicInteger clientId = new AtomicInteger(-1); | ||
|
||
return events.stream().map((event) -> switch (event) { | ||
case Event.Advance e -> { | ||
tick.addAndGet(e.ticksToAdvance()); | ||
yield null; | ||
} | ||
|
||
case Event.SetCommandSource e -> { | ||
clientId.set(e.playerIndex()); | ||
yield null; | ||
} | ||
|
||
default -> new RegisteredEvent(tick.intValue(), sources.get(clientId.intValue()), event); | ||
}).filter(Objects::nonNull).toList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at this now I might just change this to a normal enhanced for loop that modifies a list since the lambda body modifies the external state so much. Then just a normal int can be used. But I don't know if it is totally required.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it is nice to be consistent with other solutions that we use to map over the events.
@Brutus5000 don't want to leave you out this time XD |
case Event.Advance e -> { | ||
tick.addAndGet(e.ticksToAdvance()); | ||
yield null; | ||
} | ||
|
||
case Event.SetCommandSource e -> { | ||
clientId.set(e.playerIndex()); | ||
yield null; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can use the full pattern matching form here so Event.Advance(int ticksToAdvance)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Processed in 706ad77
fixed("fixed", "Fixed"), | ||
random("random", "Random"), | ||
balanced("balanced", "Balanced"), | ||
balanced_flex("balanced_flex", "Flexible balanced"), | ||
random_reveal("random_reveal", "Random and revealed"), | ||
balanced_reveal("balanced_reveal", "Balanced and revealed"), | ||
balanced_reveal_mirrored("balanced_reveal_mirrored", "Mirror balanced and revealed"), | ||
balanced_flex_reveal("balanced_flex_reveal", "Flexible balanced and revealed"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These enums should be properly cased as well
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Processed in: bdee610
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One comment that could or could not be done really
if (!(callbackTable.value().get("From") instanceof LuaData.Number from)) { | ||
yield null; | ||
} | ||
|
||
// focus army starts is 1-based instead of 0-based, to align it we subtract 1 | ||
if (from.value() - 1 <= -2) { | ||
yield null; | ||
} | ||
|
||
// TODO: this field has no meaning and can be manipulated, instead use the authorised command source. | ||
// Requires refactoring in the game! | ||
if (!(callbackTable.value().get("Sender") instanceof LuaData.String sender)) { | ||
yield null; | ||
} | ||
|
||
// TODO: apparently all players create a sim callback that contains the chat message. This hack is how we skip it, | ||
// Requires refactoring in the game! | ||
if (!Objects.equals(sender.value(), registeredEvent.source().name())) { | ||
yield null; | ||
} | ||
|
||
if (!(callbackTable.value().get("Msg") instanceof LuaData.Table msgTable)) { | ||
yield null; | ||
} | ||
|
||
// TODO: this is 1 out of the 2 legitimate fields | ||
if (!(msgTable.value().get("to") instanceof LuaData.String msgTo)) { | ||
yield null; | ||
} | ||
|
||
// TODO: this is 2 out of the 2 legitimate fields | ||
if (!(msgTable.value().get("text") instanceof LuaData.String msgText)) { | ||
yield null; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically for these you could use the full record pattern. So LuaData.Number(float value)
Just to remove all the calls of .value
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Processed in 1b9492f
In this pull request we finalize the new setup to process replays. As described in #140 , this is the final step towards separating the various phases of the parsing. With these changes we separate the following phases for the body of the replay:
New pull requests can now create additional semantics without being troubled by the token and parsing phases.