diff --git a/README.md b/README.md index fbbbd00..230877d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # lor-deck-encoder - Legends of Runeterra - Deck Encoder -This is a JavaScript ES6 port of the Legends of Runeterra deck encoder/decoder. The goal of this project is to keep up-to-date with Riot's [Implementation](https://github.com/RiotGames/LoRDeckCodes), stay as close to the original result schema as possible, and use zero external runtime dependencies (instead of testing). +This is a JavaScript ES6 port of the Legends of Runeterra deck encoder/decoder. The goal of this project is to keep up-to-date with Riot's [Implementation](https://github.com/RiotGames/LoRDeckCodes), stay as close to the original result schema as possible, and use minimal runtime dependencies (instead of testing). ## Installation @@ -21,7 +21,7 @@ The following headlines will describe the _Deck_ class interface. Example: ```js import createDeck from 'lor-deck-encoder'; -const deck = createDeck('CEBQGAIFAMJC6BABAMCBGFJUAICAGAQRAICACBIWDQOS4AIBAM4AEAIEAUIQEBADAEHQ'); +const deck = createDeck('CEBQEBADAIIQGAIFAMJC6BABAMCBGFJUAIAQCAZYAQAQKFQ4DUXAEAIEAUIQEBADAEHQ'); ``` ## Example usage diff --git a/cli.mjs b/cli.mjs index 9f1410f..fb1d3e5 100644 --- a/cli.mjs +++ b/cli.mjs @@ -3,10 +3,18 @@ import fs from 'node:fs/promises'; import minimist from 'minimist'; import stringify from 'json-stringify-pretty-compact'; import {Deck, generateDataDragon} from './index.mjs'; +import Base32 from './utils/base32.mjs'; const [code, ...parameters] = process.argv.slice(2); -console.log(Deck.fromCode(code)); +const deck = Deck.fromCode(code); +console.log(`${code} => ${deck.code} (${code === deck.code})`); + +console.log(JSON.stringify(Base32.decode(code))); +console.log(JSON.stringify(deck.list)); + +console.log(JSON.stringify(Base32.decode(deck.code))); +console.log(JSON.stringify(Deck.fromCode(deck.code).list)); if (parameters?.length) { const dragon = generateDataDragon(); diff --git a/package.json b/package.json index 305244b..a88d24c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "quibble": "^0.9.2" }, "engines": { - "node": ">=18" + "node": ">=22" }, "scripts": { "cli": "node ./cli.mjs", diff --git a/src/data-dragon.mjs b/src/data-dragon.mjs index 7f8d6ed..11dc28f 100644 --- a/src/data-dragon.mjs +++ b/src/data-dragon.mjs @@ -94,7 +94,6 @@ export default class DataDragon { * @returns {Promise} */ async initialize(language) { - if (this.cardsByCode) return; if (!LANGUAGES[language]) { language = Object.keys(LANGUAGES).find(key => key === language || key.split('_').includes(language)); if (!language) language = 'en_us'; diff --git a/src/encoder.mjs b/src/encoder.mjs index f8f2e18..cf81daf 100644 --- a/src/encoder.mjs +++ b/src/encoder.mjs @@ -34,7 +34,8 @@ export default class Encoder { const version = firstByte & 0xf; if (!skipFormatCheck && format > SUPPORTED_FORMAT) throw new SyntaxError(`Deck format ${format} is not supported (supported format ${SUPPORTED_FORMAT})`); - if (version > Factions.maxVersion) throw new ArgumentError('version', `Deck version ${version} is not supported (max supported version ${Factions.maxVersion})`); + if (version > Factions.maxVersion) + throw new ArgumentError('version', `Deck version ${version} is not supported (max supported version ${Factions.maxVersion})`); const result = new Array(); for (let count = 3; count > 0; count -= 1) { @@ -68,16 +69,17 @@ export default class Encoder { */ static encode(cards, version) { if (cards.some(card => !card?.count)) throw new Error('Invalid deck'); - version = Math.max( + version ??= Math.max( cards?.reduce((l, {factionVersion: v}) => Math.max(l, v), 0), version, INITIAL_VERSION ); const values = []; + + // cards with count > 3 are handled separately const grouped = cards.reduce( (groups, card) => { - // cards with count > 3 are handled separatly if (card.count > 3) groups.x.push(card); else groups[card.count].push(card); return groups; @@ -85,7 +87,7 @@ export default class Encoder { {3: [], 2: [], 1: [], x: []} ); - [3, 2, 1].forEach(count => { + for (let count = 3; count > 0; count--) { //build the map of set and faction combinations within the group of similar card counts const factionSetsMap = grouped[count].reduce((map, card) => { const sf = card.set * 100 + card.factionId; @@ -98,17 +100,22 @@ export default class Encoder { //The sorting convention of this encoding scheme is //First by the number of set/faction combinations in each top-level list //Second by the alphanumeric order of the card codes within those lists. - [...factionSetsMap.keys()].sort().forEach(sf => { - const cards = factionSetsMap.get(sf); - values.push(cards.length); - values.push(cards[0].set); - values.push(cards[0].factionId); - cards.sort((a, b) => a.id - b.id).forEach(card => values.push(card.id)); - }); - }); + [...factionSetsMap.entries()] + .sort(([a], [b]) => a - b) + .forEach(([, groupCards]) => { + const [firstCard] = groupCards; + values.push(groupCards.length); + values.push(firstCard.set); + values.push(firstCard.factionId); + groupCards + .map(({id}) => id) + .sort((a, b) => a - b) + .forEach(id => values.push(id)); + }); + } //Cards with 4+ are coded simply [count, set, faction, id] for each - grouped.x.sort(Card.compare).forEach(card => { + grouped.x?.sort(Card.compare).forEach(card => { values.push(card.count); values.push(card.set); values.push(card.faction.id); diff --git a/src/factions.mjs b/src/factions.mjs index 7d7ca59..3855642 100644 --- a/src/factions.mjs +++ b/src/factions.mjs @@ -20,7 +20,7 @@ const AVAILABLE_FACTIONS = [ ]; /** - * The max known version. + * The max known version, based on the available factions. */ const MAX_KNOWN_VERSION = AVAILABLE_FACTIONS.reduce((last, {version}) => Math.max(last, version), 0); diff --git a/test/encoder.spec.mjs b/test/encoder.spec.mjs index fd8cc3b..e3f6579 100644 --- a/test/encoder.spec.mjs +++ b/test/encoder.spec.mjs @@ -1,34 +1,60 @@ import Encoder from '../src/encoder.mjs'; import {ArgumentError} from '../src/errors.mjs'; import assert from 'assert'; +import Base32 from '../utils/base32.mjs'; describe('[Encoder] class tests', function () { - describe('encoder', function () { - describe('encode method', function () { - it('no cards', function () { - assert.equal(Encoder.decode('CEAAAAA').length, 0); - }); - - it('no cards skip version', function () { - assert.equal(Encoder.decode('EUAAAAA', true).length, 0); - }); - - it('check version', function () { - assert.throws(() => Encoder.decode('CZAAAAA', false), ArgumentError); - }); - - it('check format', function () { - assert.throws(() => Encoder.decode('EUAAAAA', false), SyntaxError); - }); - - it('empty value', function () { - assert.throws(() => Encoder.decode('')); - }); - - it('invalid deck', function () { - assert.throws(() => Encoder.encode([null])); - assert.throws(() => Encoder.encode([{count: 0}])); - }); + describe('decode method', function () { + it('no cards', function () { + assert.equal(Encoder.decode('CEAAAAA').length, 0); + }); + + it('no cards skip version', function () { + assert.equal(Encoder.decode('EUAAAAA', true).length, 0); + }); + + it('check version', function () { + assert.throws(() => Encoder.decode('CZAAAAA', false), ArgumentError); + }); + + it('check format', function () { + assert.throws(() => Encoder.decode('EUAAAAA', false), SyntaxError); + }); + + it('empty value', function () { + assert.throws(() => Encoder.decode('')); + }); + }); + + describe('encode method', function () { + it('invalid deck', function () { + assert.throws(() => Encoder.encode([null])); + assert.throws(() => Encoder.encode([{count: 0}])); + }); + }); + + describe('decode algorithm', function () { + + const newGrouping = cards => Object.groupBy(cards, ({count}) => (count > 3 ? 'x' : count)); + const newMapping = (grouped, count) => Map.groupBy(grouped[count] ?? [], ({set, factionId}) => set * 100 + factionId); + + const code = 'CEBAIAIABEQDINIFAEBAUEATEAYAEAIBAIYQGAIAAIDSUAQCAEBCWLIDAEAAMHJN'; + let cards, info; + + beforeEach(function () { + cards = Encoder.decode(code); + info = Base32.decode(code); + }); + + it('must match old groups', function () { + const groups = newGrouping(cards); + + newMapping(groups, 3); + }); + + it('must match lengths', function () { + assert.equal(cards.length, 18); + assert.equal(info.length, 40); }); }); }); diff --git a/utils/base32.mjs b/utils/base32.mjs index 5168a78..4497f04 100644 --- a/utils/base32.mjs +++ b/utils/base32.mjs @@ -88,13 +88,13 @@ export default class Base32 { if (!bytes?.length) return padOutput ? PADDING : ''; if (bytes.length >= 1 << 28) throw new RangeError('Value is too long to encode as base32 string'); - const length = Math.floor((bytes.length * 8 + SHIFT - 1) / SHIFT); + const calculatedLength = Math.floor((bytes.length * 8 + SHIFT - 1) / SHIFT); let padding = 0; if (padOutput) { - const rest = length % 8; - if (rest) padding = 8 - rest; + const remainder = calculatedLength % 8; + if (remainder) padding = 8 - remainder; } - const result = Buffer.alloc(length + padding); + const result = Buffer.alloc(calculatedLength + padding); let buffer = bytes[0]; let nextByte = 1;