diff --git a/javascript/.gitignore b/javascript/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/javascript/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/javascript/index.js b/javascript/index.js new file mode 100644 index 0000000..6b40dbf --- /dev/null +++ b/javascript/index.js @@ -0,0 +1,170 @@ +'use strict' + +exports.parseDeck = parseDeck +exports.encodeDeck = encodeDeck + +const b64 = require('base64-js') + +const CURRENT_VERSION = 2 +const ENCODE_PREFIX = "ADC" + + +function encodeDeck(deck) { + if (!deck || !deck.heroes || !deck.cards) throw "invalid deck" + + const heroes = deck.heroes.sort((a, b) => a.id - b.id) + const cards = deck.cards.sort((a, b) => a.id - b.id) + + const encoder = cardEncoder() + encoder.writeVar(heroes.length, 3) + heroes.forEach(it => encoder.writeCard(it.id, it.turn)) + encoder.resetPreviousId() + cards.forEach(it => encoder.writeCard(it.id, it.count)) + + const versionAndHeroes = CURRENT_VERSION << 4 | extractNBitsWithCarry(heroes.length, 3) + const checksum = computeChecksum(encoder.getBytes()) + const name = (deck.name || "").substr(0, 63) + + const header = [versionAndHeroes, checksum, name.length] + const nameArray = Array.apply(null, { length: name.length }).map((_, i) => name.charCodeAt(i)) + + const encodedDeck = b64.fromByteArray(header.concat(encoder.getBytes(), nameArray)) + const sanitizedDeckCode = ENCODE_PREFIX + encodedDeck.replace(/\//g, "-").replace(/=/g, "_") + + return sanitizedDeckCode +} + +function cardEncoder() { + const bytes = [] + var previousId = 0 + + function writeVar(value, bitsToSkip) { + if (value < (1 << bitsToSkip)) return + value = value >>> bitsToSkip + while (value > 0) { + bytes.push(extractNBitsWithCarry(value, 7)) + value = value >>> 7 + } + } + + function writeCard(id, n) { + const nPart = n <= 3 ? n - 1 : 3 + + const delta = id - previousId + previousId = id + const idPart = extractNBitsWithCarry(delta, 5) + + bytes.push((nPart << 6) | idPart) + writeVar(delta, 5) + if (n > 3) writeVar(n, 0) + } + + return { + writeVar, + writeCard, + resetPreviousId: () => previousId = 0, + getBytes: () => bytes + } +} + +function extractNBitsWithCarry(value, bits) { + const limitBit = 1 << bits + if (value < limitBit) return value + return limitBit | (value & (limitBit - 1)) +} + + + +function parseDeck(deckCode) { + if (!deckCode.startsWith(ENCODE_PREFIX)) throw "invalid deck code prefix" + const b64Str = deckCode.substr(ENCODE_PREFIX.length) + .replace(/-/g, "/") + .replace(/_/g, "=") + + const deckCodeBytes = b64.toByteArray(b64Str) + + var byteIndex = 0 + + const versionAndHeroes = deckCodeBytes[byteIndex++] + const version = versionAndHeroes >>> 4 + const checksum = deckCodeBytes[byteIndex++] + const stringLength = version > 1 ? deckCodeBytes[byteIndex++] : 0 + + if (version > CURRENT_VERSION) throw `deck code version ${version} is not supported` + + + const nameStartIndex = deckCodeBytes.length - stringLength + const deckBytes = deckCodeBytes.slice(byteIndex, nameStartIndex) + const computedChecksum = computeChecksum(deckBytes) + if (checksum != computedChecksum) throw "invalid deck code checksum" + + const decoder = cardDecoder(deckBytes) + + const heroesLength = decoder.readVar(versionAndHeroes, 3) + const heroes = [] + for (let i = 0; i < heroesLength; i++) { + heroes.push(decoder.readCard("turn")) + } + + decoder.resetPreviousId() + const cards = [] + while (decoder.hasNext()) { + cards.push(decoder.readCard("count")) + } + + const name = String.fromCharCode.apply(null, deckCodeBytes.slice(nameStartIndex)) + + return { name, heroes, cards } +} + +function cardDecoder(bytes) { + const bytesLength = bytes.length + var i = 0 + var previousId = 0 + + function readVar(baseValue, baseBits) { + var result = 0 + if (baseBits !== 0) { + let continueBit = 1 << baseBits + result = baseValue & (continueBit - 1) + if ((baseValue & continueBit) === 0) return result + } + + var currentShift = baseBits + var currentByte + do { + if (i >= bytesLength) throw "invalid deck code" + currentByte = bytes[i++] + result |= ((currentByte & 127) << currentShift) + currentShift += 7 + } while ((currentByte & 128) > 0) + + return result + } + + function readCard(nName = "n") { + if (i >= bytesLength) throw "invalid deck code" + const header = bytes[i++] + const id = previousId + readVar(header, 5) + previousId = id + var n = (header >>> 6) + if (n === 3) { + n = readVar(0, 0) // n is higher than 3 and is encoded separately + } else { + n += 1 // adding 1 since zero values are not encoded here + } + + return { id, [nName]: n } + } + + return { + readVar, + readCard, + hasNext: () => i < bytesLength, + resetPreviousId: () => previousId = 0 + } +} + +function computeChecksum(bytes) { + return 0xFF & bytes.reduce((a, b) => a + b, 0) +} diff --git a/javascript/package-lock.json b/javascript/package-lock.json new file mode 100644 index 0000000..b7b380f --- /dev/null +++ b/javascript/package-lock.json @@ -0,0 +1,201 @@ +{ + "name": "artifact-deck-code", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "commander": { + "version": "2.15.1", + "resolved": "http://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", + "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", + "dev": true, + "requires": { + "browser-stdout": "1.3.1", + "commander": "2.15.1", + "debug": "3.1.0", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.5", + "he": "1.1.1", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "supports-color": "5.4.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + } + } +} diff --git a/javascript/package.json b/javascript/package.json new file mode 100644 index 0000000..8e49698 --- /dev/null +++ b/javascript/package.json @@ -0,0 +1,17 @@ +{ + "name": "artifact-deck-code", + "version": "0.0.1", + "description": "Module for encoding/decoding Artifact deck codes.", + "author": "NorthFury", + "license": "BSD-3-Clause", + "main": "index.js", + "scripts": { + "test": "mocha --harmony test" + }, + "dependencies": { + "base64-js": "1.3.0" + }, + "devDependencies": { + "mocha": "5.2.0" + } +} diff --git a/javascript/test/deck.json b/javascript/test/deck.json new file mode 100644 index 0000000..f34944b --- /dev/null +++ b/javascript/test/deck.json @@ -0,0 +1,87 @@ +{ + "name": "Green/Black Example", + "heroes": [ + { + "id": 4005, + "turn": 2 + }, + { + "id": 10014, + "turn": 1 + }, + { + "id": 10017, + "turn": 3 + }, + { + "id": 10026, + "turn": 1 + }, + { + "id": 10047, + "turn": 1 + } + ], + "cards": [ + { + "id": 3000, + "count": 2 + }, + { + "id": 3001, + "count": 1 + }, + { + "id": 10091, + "count": 3 + }, + { + "id": 10102, + "count": 3 + }, + { + "id": 10128, + "count": 3 + }, + { + "id": 10165, + "count": 3 + }, + { + "id": 10168, + "count": 3 + }, + { + "id": 10169, + "count": 3 + }, + { + "id": 10185, + "count": 3 + }, + { + "id": 10223, + "count": 1 + }, + { + "id": 10234, + "count": 3 + }, + { + "id": 10260, + "count": 1 + }, + { + "id": 10263, + "count": 1 + }, + { + "id": 10322, + "count": 3 + }, + { + "id": 10354, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/javascript/test/index.js b/javascript/test/index.js new file mode 100644 index 0000000..7a87442 --- /dev/null +++ b/javascript/test/index.js @@ -0,0 +1,65 @@ +'use strict' + +const assert = require('assert') +const { parseDeck, encodeDeck } = require('../') + +const deck = require('./deck.json') +const DECK_CODE = "ADCJWkTZX05uwGDCRV4XQGy3QGLmqUBg4GQJgGLGgO7AaABR3JlZW4vQmxhY2sgRXhhbXBsZQ__" + +describe('encodeDeck', function () { + it('should encode a deck', function () { + const result = encodeDeck(deck) + assert.equal(result, DECK_CODE) + }) + + it('should encode a deck with unordered cards and heroes', function () { + const newDeck = { + name: deck.name, + heroes: deck.heroes.slice(0).reverse(), + cards: deck.cards.slice(0).reverse() + } + const result = encodeDeck(deck) + assert.equal(result, DECK_CODE) + }) +}) + +describe('parseDeck', function () { + it('should decode v2 deck code', function () { + const result = parseDeck(DECK_CODE) + assert.equal(result.name, deck.name) + assert.equal(areTheCardArraysEqual(result.heroes, deck.heroes), true) + assert.equal(areTheCardArraysEqual(result.cards, deck.cards), true) + }) + + it('should decode v1 deck code', function () { + const result = parseDeck("ADCFWllfTm7AYMJFXhdAbLdAYuapQGDgZAmAYsaA7sBoAE_") + assert.equal(result.name, "") + assert.equal(areTheCardArraysEqual(result.heroes, deck.heroes), true) + assert.equal(areTheCardArraysEqual(result.cards, deck.cards), true) + }) +}) + + +function areTheCardArraysEqual(a, b) { + if (a.length !== b.length) return false + a.sort((a, b) => a.id - b.id) + b.sort((a, b) => a.id - b.id) + + for (let i = 0, n = a.length; i < n; i++) { + if (!areTheObjectsEqual(a[i], b[i])) return false + } + + return true +} + +function areTheObjectsEqual(a, b) { + const ak = Object.keys(a) + const bk = Object.keys(b) + if (ak.length !== bk.length) return false + + for (let i = 0, n = ak.length; i < n; i++) { + if (a[ak[i]] !== b[bk[i]]) return false + } + + return true +}