Commit 439c6df1 authored by nanahira's avatar nanahira

remove js-base64 dep, add ygomobile deck code

parent 7eac658e
import { fromBase64Url, toBase64Url } from './src/base64';
import { BufferWriter, countItems } from './src/utils';
import { Base64 } from 'js-base64';
import { fromYGOMobileDeckUri, toYGOMobileDeckUri } from './src/ygom';
export default class YGOProDeck {
main: number[] = [];
extra: number[] = [];
side: number[] = [];
name?: string;
bufferLength() {
const counted = [this.main, this.extra, this.side].map(countItems);
......@@ -31,7 +33,7 @@ export default class YGOProDeck {
}
toEncodedString() {
return Base64.fromUint8Array(this.toUint8Array(), true);
return toBase64Url(this.toUint8Array());
}
toString() {
......@@ -58,7 +60,7 @@ export default class YGOProDeck {
}
fromEncodedString(str: string) {
return this.fromUint8Array(Base64.toUint8Array(str));
return this.fromUint8Array(fromBase64Url(str));
}
static fromEncodedString(str: string) {
......@@ -110,4 +112,23 @@ export default class YGOProDeck {
cards.forEach((id) => writer.writeUint32LE(id));
return writer.buffer;
}
fromYGOMobileDeckUri(uri: string): YGOProDeck {
const parsed = fromYGOMobileDeckUri(uri);
this.main = parsed.main;
this.extra = parsed.extra;
this.side = parsed.side;
this.name = parsed.name;
return this;
}
static fromYGOMobileDeckUri(uri: string): YGOProDeck {
return new YGOProDeck().fromYGOMobileDeckUri(uri);
}
toYGOMobileDeckUri(): string {
return toYGOMobileDeckUri(this.main, this.extra, this.side, this.name && {
name: this.name,
});
}
}
{
"name": "myproject",
"name": "ygopro-deck-encode",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "myproject",
"name": "ygopro-deck-encode",
"version": "1.0.5",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
},
"devDependencies": {
"@types/jest": "^29.4.0",
"@types/node": "^18.13.0",
......@@ -3878,11 +3875,6 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/js-base64": {
"version": "3.7.5",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz",
"integrity": "sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
......
......@@ -7,8 +7,8 @@
"types": "dist/index.d.ts",
"scripts": {
"lint": "eslint --fix .",
"compile:cjs": "esbuild index.ts --outfile=dist/index.cjs --bundle --sourcemap --platform=node --target=es2019 --external:js-base64",
"compile:esm": "esbuild index.ts --outfile=dist/index.mjs --bundle --sourcemap --platform=neutral --target=esnext --external:js-base64",
"compile:cjs": "esbuild index.ts --outfile=dist/index.cjs --bundle --sourcemap --platform=node --target=es2019",
"compile:esm": "esbuild index.ts --outfile=dist/index.mjs --bundle --sourcemap --platform=neutral --target=esnext",
"compile:types": "tsc --emitDeclarationOnly --declaration",
"build": "rimraf dist && npm run compile:cjs && npm run compile:esm && npm run compile:types",
"test": "jest --passWithNoTests",
......@@ -57,8 +57,5 @@
"rimraf": "^4.1.2",
"ts-jest": "^29.0.5",
"typescript": "^4.9.5"
},
"dependencies": {
"js-base64": "^3.7.5"
}
}
export function toBase64Url(bytes: Uint8Array): string {
const base64 = typeof Buffer !== 'undefined'
? Buffer.from(bytes).toString('base64')
: btoa(String.fromCharCode(...bytes));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function fromBase64Url(encoded: string): Uint8Array {
const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
if (typeof Buffer !== 'undefined') {
return Uint8Array.from(Buffer.from(padded, 'base64'));
}
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
import { fromBase64Url, toBase64Url } from "./base64";
// === 常量区域 ===
const QUERY_YGO_TYPE = 'ygotype';
const QUERY_VERSION = 'v';
const ARG_DECK = 'deck';
const QUERY_DECK = 'd';
const QUERY_NAME = 'name';
const URL_SCHEME_HTTP = 'http';
const URL_HOST_DECK = 'deck.ourygo.top';
// === 工具函数 ===
function toBinary(value: number, length: number): string {
return value.toString(2).padStart(length, '0');
}
function encodeCards(cards: number[]): string {
const bits: string[] = [];
for (let i = 0; i < cards.length; ) {
const id = cards[i];
let count = 1;
while (i + count < cards.length && cards[i + count] === id && count < 3) count++;
const prefix = count === 2 ? '10' : count === 3 ? '11' : '01';
bits.push(prefix + toBinary(id, 27));
i += count;
}
return bits.join('');
}
function countUnique(cards: number[]): number {
let num = 0;
for (let i = 0; i < cards.length; i++) {
const id = cards[i];
if (id > 0) {
num++;
if (i < cards.length - 1 && cards[i + 1] === id) {
i++;
if (i < cards.length - 1 && cards[i + 1] === id) {
i++;
}
}
}
}
return num;
}
// === 主函数 ===
export function toYGOMobileDeckUri(
main: number[],
extra: number[],
side: number[],
customParams: Record<string, string> = {},
): string {
const mNum = countUnique(main);
const eNum = countUnique(extra);
const sNum = countUnique(side);
const header = toBinary(mNum, 8) + toBinary(eNum, 4) + toBinary(sNum, 4);
let bitString = header + encodeCards(main) + encodeCards(extra) + encodeCards(side);
while (bitString.length % 8 !== 0) bitString += '0';
const bytes = new Uint8Array(bitString.length / 8);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(bitString.slice(i * 8, i * 8 + 8), 2);
}
let encoded = toBase64Url(bytes)
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(customParams)) {
searchParams.set(key, value);
}
searchParams.set(QUERY_YGO_TYPE, ARG_DECK);
searchParams.set(QUERY_VERSION, '1');
searchParams.set(QUERY_DECK, encoded);
return `${URL_SCHEME_HTTP}://${URL_HOST_DECK}?${searchParams.toString()}`;
}
export function fromYGOMobileDeckUri(uri: string): {
main: number[];
extra: number[];
side: number[];
name?: string;
} {
const url = new URL(uri);
if (url.searchParams.get(QUERY_YGO_TYPE) !== ARG_DECK) {
throw new Error('Not a YGO Mobile deck URI');
}
let encoded = url.searchParams.get(QUERY_DECK);
if (!encoded) throw new Error('Missing deck data');
const bytes = fromBase64Url(encoded);
const bits = Array.from(bytes)
.map((b) => b.toString(2).padStart(8, '0'))
.join('');
const mNum = parseInt(bits.slice(0, 8), 2);
const eNum = parseInt(bits.slice(8, 12), 2);
const sNum = parseInt(bits.slice(12, 16), 2);
const all = mNum + eNum + sNum;
const cards: number[] = [];
let pos = 16;
const res = {
main: [] as number[],
extra: [] as number[],
side: [] as number[],
}
let i = 0;
while (pos + 29 <= bits.length && cards.length < all) {
const countBits = bits.slice(pos, pos + 2);
const count = countBits === '10' ? 2 : countBits === '11' ? 3 : 1;
const id = parseInt(bits.slice(pos + 2, pos + 29), 2);
const field = i < mNum ? 'main' : i < mNum + eNum ? 'extra' : 'side';
res[field].push(...Array(count).fill(id));
pos += 29;
i++;
}
return {
...res,
name: url.searchParams.get(QUERY_NAME),
};
}
......@@ -51,4 +51,18 @@ describe('Sample test.', () => {
expect(decoded.extra).toStrictEqual(deck.extra);
expect(decoded.side).toStrictEqual(deck.side);
})
it('should encode and decode ygomobile', () => {
const uri = 'http://deck.ourygo.top?name=%E7%99%BD%E9%93%B6%E5%9F%8E%E7%A0%81&ygotype=deck&v=1&d=J-xK4Mka02AuEAMf2dV6mj7aRemuJNQJK8BwrcEYh0MqOJqBJSgYqUwxPEVhOG8R18WWVKmzkT-xEdwxbmGVkkOdrVIpufaYI3Hs8oOrcya8Bi40h9G79iFW80rq-o6P-AHsusPY5nmvHLol0DIqEykESVlf6VSbxVJp-j7XZtTE0XvmJW80rqH28R4rgyRovOusVJzbutenYFBA_cyK6d3UWcQkJQlLjaroWavH-INFA56k5DQNWOQ1gpvxrKVBLgEk1olpolKmSgriramLlgtBK1EQ6C6oi94ZyHe7N6T7mqE6peds7mahrORP6A';
const deck = new YGOProDeck().fromYGOMobileDeckUri(uri);
expect(deck.main).toHaveLength(58);
expect(deck.extra).toHaveLength(15);
expect(deck.side).toHaveLength(15);
expect(deck.main[0]).toBe(22812963);
expect(deck.extra[0]).toBe(12381100);
expect(deck.side[0]).toBe(20292186);
const uri2 = deck.toYGOMobileDeckUri();
expect(uri2).toBe(uri);
})
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment