Skip to content
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

Add serialization of OathGames to save strings. #3

Merged
merged 1 commit into from
Jul 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 169 additions & 50 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@

import { CardNameIndexes, Citizenship, Oath, OathGame, PlayerColor, SiteNameIndexes } from './interfaces';
import {
Card,
CardName,
CardNameIndexes,
Citizenship,
Oath,
OathGame,
PlayerColor,
PlayerCitizenship,
SiteName,
SiteNameIndexes,
Suit,
} from './interfaces';

const CHRONICLE_NAME_MIN_LENGTH = 1;
const CHRONICLE_NAME_MAX_LENGTH = 255;
Expand All @@ -13,12 +25,7 @@ enum SavefileDataType {
PlayerStatus,
ExileCitizenStatus,
Oath,
SuitDiscord,
SuitHearth,
SuitNomad,
SuitArcane,
SuitOrder,
SuitBeast,
Site1,
Site2,
Site3,
Expand Down Expand Up @@ -48,14 +55,7 @@ const Indices: Record<SavefileDataType, (offset: number) => ({ start: number, en
[SavefileDataType.ExileCitizenStatus]: (offset: number) => ({ start: offset + 2, end: offset + 4 }),

[SavefileDataType.Oath]: (offset: number) => ({ start: offset + 4, end: offset + 6 }),

[SavefileDataType.SuitDiscord]: (offset: number) => ({ start: offset + 6, end: offset + 7 }),
[SavefileDataType.SuitHearth]: (offset: number) => ({ start: offset + 7, end: offset + 8 }),
[SavefileDataType.SuitNomad]: (offset: number) => ({ start: offset + 8, end: offset + 9 }),
[SavefileDataType.SuitArcane]: (offset: number) => ({ start: offset + 9, end: offset + 10 }),
[SavefileDataType.SuitOrder]: (offset: number) => ({ start: offset + 10, end: offset + 11 }),
[SavefileDataType.SuitBeast]: (offset: number) => ({ start: offset + 11, end: offset + 12 }),

[SavefileDataType.SuitOrder]: (offset: number) => ({ start: offset + 6, end: offset + 12 }),
[SavefileDataType.Site1]: (offset: number) => ({ start: offset + 12, end: offset + 14 }),
[SavefileDataType.Site2]: (offset: number) => ({ start: offset + 20, end: offset + 22 }),
[SavefileDataType.Site3]: (offset: number) => ({ start: offset + 28, end: offset + 30 }),
Expand All @@ -73,39 +73,166 @@ const Indices: Record<SavefileDataType, (offset: number) => ({ start: number, en
// offset dynamically set to dispossessed deck size between these places

[SavefileDataType.RelicDeckSize]: (offset: number) => ({ start: offset + 0, end: offset + 2 }),
[SavefileDataType.ExileCitizenStatusPrev]: (offset: number) => ({ start: offset + 2, end: offset + 4 }),
[SavefileDataType.WinningColor]: (offset: number) => ({ start: offset + 4, end: offset + 6 }),

[SavefileDataType.ExileCitizenStatusPrev]: (offset: number) => ({ start: offset + 0, end: offset + 2 }),
[SavefileDataType.WinningColor]: (offset: number) => ({ start: offset + 2, end: offset + 4 }),
};

function parseCitizenshipByte(byte: number): Record<PlayerColor, Citizenship> {
const citizenships: Record<PlayerColor, Citizenship> = {
function parseCitizenshipByte(byte: number): PlayerCitizenship {
const citizenships: PlayerCitizenship = {
[PlayerColor.Brown]: Citizenship.Exile,
[PlayerColor.Yellow]: Citizenship.Exile,
[PlayerColor.White]: Citizenship.Exile,
[PlayerColor.Blue]: Citizenship.Exile,
[PlayerColor.Red]: Citizenship.Exile
};

if(byte & 0x10) citizenships[PlayerColor.Brown] = Citizenship.Citizen;
if(byte & 0x08) citizenships[PlayerColor.Yellow] = Citizenship.Citizen;
if(byte & 0x04) citizenships[PlayerColor.White] = Citizenship.Citizen;
if(byte & 0x02) citizenships[PlayerColor.Blue] = Citizenship.Citizen;
if(byte & 0x01) citizenships[PlayerColor.Red] = Citizenship.Citizen;
if(byte & colorMask(PlayerColor.Brown)) citizenships[PlayerColor.Brown] = Citizenship.Citizen;
if(byte & colorMask(PlayerColor.Yellow)) citizenships[PlayerColor.Yellow] = Citizenship.Citizen;
if(byte & colorMask(PlayerColor.White)) citizenships[PlayerColor.White] = Citizenship.Citizen;
if(byte & colorMask(PlayerColor.Blue)) citizenships[PlayerColor.Blue] = Citizenship.Citizen;
if(byte & colorMask(PlayerColor.Red)) citizenships[PlayerColor.Red] = Citizenship.Citizen;

return citizenships;
}

function parseColorByte(byte: number): PlayerColor {
if(byte & 0x10) return PlayerColor.Brown;
if(byte & 0x08) return PlayerColor.Yellow;
if(byte & 0x04) return PlayerColor.White;
if(byte & 0x02) return PlayerColor.Blue;
if(byte & 0x01) return PlayerColor.Red;
function parseColorByte(byte: number): PlayerColor {
if(byte & colorMask(PlayerColor.Purple)) return PlayerColor.Purple;
if(byte & colorMask(PlayerColor.Brown)) return PlayerColor.Brown;
if(byte & colorMask(PlayerColor.Yellow)) return PlayerColor.Yellow;
if(byte & colorMask(PlayerColor.White)) return PlayerColor.White;
if(byte & colorMask(PlayerColor.Blue)) return PlayerColor.Blue;
if(byte & colorMask(PlayerColor.Red)) return PlayerColor.Red;
}

export function parseOathTTSSavefileString(saveDataString: string): OathGame {
function colorMask(color: PlayerColor): number {
return {
[PlayerColor.Purple]: 0x20,
[PlayerColor.Brown]: 0x10,
[PlayerColor.Yellow]: 0x08,
[PlayerColor.White]: 0x04,
[PlayerColor.Blue]: 0x02,
[PlayerColor.Red]: 0x01
}[color];
}

function genCitizenshipByte(citizenship: PlayerCitizenship): number {
let byte = 0x00;
if(citizenship.Brown === Citizenship.Citizen) byte |= colorMask(PlayerColor.Brown);
if(citizenship.Yellow === Citizenship.Citizen) byte |= colorMask(PlayerColor.Yellow);
if(citizenship.White === Citizenship.Citizen) byte |= colorMask(PlayerColor.White);
if(citizenship.Blue === Citizenship.Citizen) byte |= colorMask(PlayerColor.Blue);
if(citizenship.Red === Citizenship.Citizen) byte |= colorMask(PlayerColor.Red);
return byte;
}

// Serialize `num` into a hex string `width` chars wide.
//
// The result is 0-padded and matches the format of the TTS hex encodings
// (uppercase).
function hex(num: number, width: number): string {
const str = num.toString(16).padStart(width, '0').toUpperCase();
if (str.length > width) {
throw new Error(
`number ${num} is wider than ${width} when encoded: ${str}`
);
}
return str;
}

// Serialize a deck into its hex-encoded savefile format.
//
// The first byte is the size of the deck, and each subsequent byte is the id of
// a card in the deck, in order.
function serializeDeck(deck: Card[]): string {
let bytes = [deck.length, ...deck.map((card) => CardName[card.name])];
return bytes.map((byte) => hex(byte, 2)).join('');
}

// Serialize a structured `OathGame` into a save file string.
//
// Inverse of `parseOathTTSSavefileString`.
export function serializeOathGame(game: OathGame): string {
const oathMajor = parseInt(game.version.major, 10);
const oathMinor = parseInt(game.version.minor, 10);
const oathPatch = parseInt(game.version.minor, 10);

if (oathMajor < 3 || (oathMajor === 3 && oathMinor < 1)) {
throw new Error('Oath savefile version 3.1.0 is the minimum required.');
}

// See `basicDataString` in lua mod for format.
const basicData: string[] = [
hex(oathMajor, 2),
hex(oathMinor, 2),
hex(oathPatch, 2),
hex(game.gameCount, 4),
hex(game.chronicleName.length, 2),
game.chronicleName,

// TODO: in >= 3.3.2, this empty byte is replaced with the active player
// status. This needs to be added to `OathGame` and the parser and then
// added here.
hex(0, 2), // empty byte

hex(genCitizenshipByte(game.playerCitizenship), 2),
hex(Oath[game.oath], 2),
...game.suitOrder.map((suit) => hex(suit, 1)),
];


// See `mapDataString` in lua mod for format.
const mapData: string[] = game.sites.map((site) => {
const siteId = SiteName[site.name];
const bytes: number[] = [siteId];
if (site.ruined && siteId !== SiteName.NONE) {
bytes[0] += 24;
}
site.cards.forEach((card) => {
bytes.push(CardName[card.name]);
});
return bytes.map((byte) => hex(byte, 2)).join('');
});

const worldDeck: string = serializeDeck(game.world);
const dispossessedDeck: string = serializeDeck(game.dispossessed);
const relicDeck: string = serializeDeck(game.relics);

let chunks = [
...basicData,
...mapData,
worldDeck,
dispossessedDeck,
relicDeck,
];

// Add previous game data if >= 3.3.1
(() => {
// TODO: should use a semver package for version checks.
if (oathMajor < 3) return;
if (oathMajor === 3) {
if (oathMinor < 3) return;
if (oathMinor === 3) {
if (oathPatch < 1) return;
}
}

// See `previousGameInfoString` in lua mod for format.
// TODO: `OathGame` doesn't yet hold the previous winner's name,
// so use the default from the lua mod.
const winnerSteamName = "UNKNOWN";
const previousGameData: string[] = [
hex(genCitizenshipByte(game.prevPlayerCitizenship), 2),
hex(colorMask(game.winner), 2),
hex(winnerSteamName.length, 2),
winnerSteamName,
];
chunks = [...chunks, ...previousGameData];
})();

return chunks.join('');
}

export function parseOathTTSSavefileString(saveDataString: string): OathGame {
let parseOffsetForName = 0;

function getHexFromStringAsNumber(startIndex: number, endIndex: number): number {
Expand Down Expand Up @@ -143,7 +270,7 @@ export function parseOathTTSSavefileString(saveDataString: string): OathGame {
minor: oathMinor.toString(),
patch: oathPatch.toString()
};

if(oathMajor < 3 && oathMinor < 1) {
throw new Error('Oath savefile version 3.1.0 is the minimum required.');
}
Expand Down Expand Up @@ -181,22 +308,14 @@ export function parseOathTTSSavefileString(saveDataString: string): OathGame {
game.oath = Oath[getHexByIndex(SavefileDataType.Oath)];
if(!game.oath) throw new Error('Invalid Oath value was found while parsing the savefile.');

// make sure all suit codes are found
game.suitOrder = [
SavefileDataType.SuitDiscord,
SavefileDataType.SuitHearth,
SavefileDataType.SuitNomad,
SavefileDataType.SuitArcane,
SavefileDataType.SuitOrder,
SavefileDataType.SuitBeast
]
.map(suitSlot => {
return { suit: SavefileDataType[suitSlot].split('Suit')[1], order: getHexByIndex(suitSlot) }
})
.reduce((prev, cur) => {
prev[cur.order] = cur.suit;
return prev;
}, []);
// Load suit order. This is unused in retail Oath but still
// part of the save file format.
game.suitOrder = [];
const { start: suitOrderStart, end: suitOrderEnd } = getStartEndByIndex(SavefileDataType.SuitOrder);
for (let i = suitOrderStart; i < suitOrderEnd; i++) {
let suit = getHexFromStringAsNumber(i, i + 1);
game.suitOrder.push(suit);
}

// load sites
game.sites = [
Expand Down Expand Up @@ -273,7 +392,7 @@ export function parseOathTTSSavefileString(saveDataString: string): OathGame {
const prevExileCitizenStatusByte = getHexByIndex(SavefileDataType.ExileCitizenStatusPrev);
game.prevPlayerCitizenship = parseCitizenshipByte(prevExileCitizenStatusByte);

const winnerColor = parseColorByte(SavefileDataType.WinningColor);
const winnerColor = parseColorByte(getHexByIndex(SavefileDataType.WinningColor));
game.winner = winnerColor;
};

Expand All @@ -282,4 +401,4 @@ export function parseOathTTSSavefileString(saveDataString: string): OathGame {
parseData_3_1_1();

return game as OathGame;
};
};
8 changes: 7 additions & 1 deletion src/interfaces/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export enum Oath {
}

export enum PlayerColor {
Purple = 'Purple',
Brown = 'Brown',
Yellow = 'Yellow',
White = 'White',
Expand All @@ -27,4 +28,9 @@ export enum PlayerColor {
export enum Citizenship {
Exile = 'Exile',
Citizen = 'Citizen'
}
}

export type PlayerCitizenship = Omit<
Record<PlayerColor, Citizenship>,
PlayerColor.Purple
>;
13 changes: 9 additions & 4 deletions src/interfaces/oathgame.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@

import { Citizenship, PlayerColor, Suit } from './enums';
import {
Citizenship,
PlayerColor,
PlayerCitizenship,
Suit,
Oath,
} from './enums';

export interface Card {
name: string;
Expand All @@ -21,14 +26,14 @@ export interface OathGame {
gameCount: number;
chronicleName: string;

playerCitizenship: Record<PlayerColor, Citizenship>;
playerCitizenship: PlayerCitizenship;
oath: string;
suitOrder: Suit[];
sites: Site[];
world: Card[];
dispossessed: Card[];
relics: Card[];

prevPlayerCitizenship: Record<PlayerColor, Citizenship>;
prevPlayerCitizenship: PlayerCitizenship;
winner: PlayerColor;
}
19 changes: 19 additions & 0 deletions test/inverseParse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { parseOathTTSSavefileString, serializeOathGame } from "../src";

// Verify that a known set of savefile strings can be parsed and then serialized
// back into the original savefile string.
const testValues = [
`030301000210Empire and Exile0002010123450CFFFFDF22FFFFFF12FFFFFF2EFFFFFF25FFFFFF05FFFFFF21FFFFFF1EFFFFFF3B3F67266B0488D6A316D5B9A87CD2A0867A9C1966D3337649B268D45401AFB0C04092610DB6937F413996943647B157B7659013A6956C519E89557306C39A64503B3213E0E2E7DBDDDCDEE6E1E9E3EDDAE8E4ECEBE5EA000407UNKNOWN`,
`030100000710Empire and Exile00180234152011FFFFFF21FFFFFF0AFFFFFF25FFFFFF1FFFFFFF05FFFFFF2EFFFFFF2AFFFFFF4107D313A90BC301411BD4B96FBF0509399EA684173132A89AD6D223D53F107F481214751F438CA3352F2A64614B080AAFA20F1D8A727D1EC0332D55605B2C8B3E211E110C222E201A290D261502242803192B2718253004341606000E4A971CAB02E8DE`,
`030301000210Empire and Exile0002010123450CFFFFDF22FFFFFF12FFFFFF2EFFFFFF25FFFFFF05FFFFFF21FFFFFF1EFFFFFF3B3F67266B0488D6A316D5B9A87CD2A0867A9C1966D3337649B268D45401AFB0C04092610DB6937F413996943647B157B7659013A6956C519E89557306C39A64503B3213E0E2E7DBDDDCDEE6E1E9E3EDDAE8E4ECEBE5EA000407UNKNOWN`,
`030200000212Benis the Dinosaur000C0201234511FFDDEC0724FFED1521C8DB08FFFFFF0B0CFFFF0502FFFF25FFFFFF2BFFFFFF36D2300AD6293C33151C2E076BD30B0F0938137C321018D5231F482F1E35D41D312C118200222D140E05041734282008250D19062B1B01062A1A1216262710EAE6E0E3DCDEE1E4E9E2E7DAEBE5E8DF`,
`030200000210The Horder Order0010000123450343FFDE1621C6DC133B86E222FFFFFF26FFFFFF0CFFFFE41EFFFFFF19FFFFFF371B6D76D4D61A330A575AAC31A6D3B32AD2D59E16746E0C249C04A510813875159BBC2545A3711769B9BB2880B11E3F5B410B60050DB619064D5C1F02992B10DDE1EAE9EBE7E6E5DAE3EDDFDBE8ECE0`,
`03020000011FBrass Horses Tilting at Shadows00000101234514FFFFEB2BFFFFFF03FFE1FF2DFFFFFF19FFFFFF0EFFFFEC27FFFFFF1DFFFFFF3691BA1C416E4447858F8CC59640053E07A05379A79D482818316749846106ACB260C47A224E641BA4BF2BC2B089561E042E15529A590A067C2CB30C51A311DADFE5DBE9EDEAE6DDDCE7E4E8E0E3DEE2`,
];
testValues.forEach((val) => {
const parsed = parseOathTTSSavefileString(val);
let serialized = serializeOathGame(parsed);
if (val !== serialized) {
throw new Error(`not equal:\nin:\t${val}\nout:\t${serialized}`);
}
});