diff --git a/CHANGELOG.md b/CHANGELOG.md index a3438da..a662751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,25 +4,37 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.0.1] - 18/07/2024 + +- Upgrade to schema version v4.0.0 +- DNA is now a serialized object +- DNA is not unique anymore + ## [v0.7.9] - 24/04/2024 + - Add new eggs and nefties for the Citrine version of Seekers of Tokane ## [v0.7.3] - 16/01/2024 + - Wassie added to Quantum eggs ## [v0.7.0] - 06/12/2023 + - Introduce Cybertooth and Wassie - Add Fen and Moss eggs - Update configuration format for adventure stats ## [v0.6.0] - 18/09/2023 + - Introduce grade for DNA generation - Add standard egg support in EggFactory ## [v0.5.4] - 19/09/2023 + - Sync dictionary with ability names and descriptions from unity team ## [v0.5.1] - 10/08/2023 + - Adjust Chocomint stats ## [v0.5.0] - 03/08/2023 diff --git a/README.md b/README.md index bee2c0e..7b09eac 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ const parsed = df.parse(dna); console.log(parsed); ``` -> See `Parse` interface for more details on [df.parse](./ts/src/interfaces/types.ts)'s output. +> See `ParseV2` interface for more details on [df.parse](./ts/src/interfaces/types.ts)'s output. ### Get all eggs diff --git a/ts/package.json b/ts/package.json index 339f878..4935f02 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@aurory/dnajs", - "version": "0.7.11", + "version": "1.0.1", "repository": { "type": "git", "url": "git+https://github.com/Aurory-Game/dna.git" @@ -12,6 +12,7 @@ "sp": "ts-node $1", "test": "ts-mocha -p ./tsconfig.json -t 2500000 --exit tests/**/*.spec.ts", "test:dna": "ts-mocha -p ./tsconfig.json -t 2500000 --exit tests/dna.spec.ts", + "test:dna2": "ts-mocha -p ./tsconfig.json -t 2500000 --exit tests/dna.v2.spec.ts", "test:distribution": "ts-mocha -p ./tsconfig.json -t 2500000 --exit tests/distribution.spec.ts", "build": "tsc -p tsconfig.build.json && cp -r src/deps lib/", "prepare": "cd .. && husky install && cd ts && yarn build", @@ -55,6 +56,7 @@ "camel-case": "^4.1.2", "dotenv": "^16.3.1", "google-spreadsheet": "^3.3.0", + "lz-string": "^1.5.0", "randombytes": "^2.1.0", "snake-case": "^3.0.4" } diff --git a/ts/src/adventure_stats.ts b/ts/src/adventure_stats.ts index c72f4bc..4d5785d 100644 --- a/ts/src/adventure_stats.ts +++ b/ts/src/adventure_stats.ts @@ -7,103 +7,9 @@ import { AdvStatsJSON, ParseDataPerc, AdvStatsJSONValue, + NeftyCodeName, } from './interfaces/types'; -/** - * Allow to pick a random number from an array, but in a deterministic way. - * The same input array will produce the same output number. - */ -function deterministicRandomPicker(arr: number[]): number { - const deterministicRandoms = arr.map((value, index, array) => { - const prevIndex = index === 0 ? array.length - 1 : index - 1; - const nextIndex = index === array.length - 1 ? 0 : index + 1; - return (array[prevIndex] + array[nextIndex] + value) % array.length; - }); - arr.sort((a, b) => { - const indexA = arr.indexOf(a); - const indexB = arr.indexOf(b); - return deterministicRandoms[indexA] - deterministicRandoms[indexB]; - }); - return arr[0]; -} - -function removeGlitched(targetAverage: number, adventuresStatsOriginal: number[]): number[] { - const adventuresStats = [...adventuresStatsOriginal]; - const maxNum = Math.max(...adventuresStats); - const maxIndex = adventuresStats.indexOf(maxNum); - adventuresStats[maxIndex] = 6; - - while (floorAverage(adventuresStats) !== targetAverage) { - const validIndices = adventuresStats.reduce((indices, stat, index) => { - if (stat !== 0 && index !== maxIndex) { - indices.push(index); - } - return indices; - }, [] as number[]); - - const indexToModify = deterministicRandomPicker(validIndices); - adventuresStats[indexToModify] -= 1; - } - - return adventuresStats; -} - -function makeGlitched(targetAverage: number, adventuresStatsOriginal: number[]): number[] { - const adventuresStats = adventuresStatsOriginal.map((num) => (num > 5 ? 5 : num)); - - while (floorAverage(adventuresStats) !== targetAverage) { - const validIndices = adventuresStats.reduce((indices, stat, index) => { - if (stat !== 5) { - indices.push(index); - } - return indices; - }, [] as number[]); - - const indexToModify = deterministicRandomPicker(validIndices); - adventuresStats[indexToModify] += 1; - } - - return adventuresStats; -} - -function makeSchimmering(targetAverage: number, adventuresStatsOriginal: number[]): number[] { - const adventuresStats = adventuresStatsOriginal.map((num) => (num < 95 ? 95 : num)); - while (floorAverage(adventuresStats) !== targetAverage) { - const validIndices = adventuresStats.reduce((indices, stat, index) => { - if (stat !== 95) { - indices.push(index); - } - return indices; - }, [] as number[]); - - const indexToModify = deterministicRandomPicker(validIndices); - adventuresStats[indexToModify] -= 1; - } - - return adventuresStats; -} - -function removeSchimmering(targetAverage: number, adventuresStatsOriginal: number[]): number[] { - const adventuresStats = [...adventuresStatsOriginal]; - const min = Math.min(...adventuresStats); - const minIndex = adventuresStats.indexOf(min); - adventuresStats[minIndex] = 94; - - while (floorAverage(adventuresStats) !== targetAverage) { - const validIndices = adventuresStats.reduce((indices, stat, index) => { - if (stat !== 100 && index !== minIndex) { - indices.push(index); - } - return indices; - }, [] as number[]); - - const indexToModify = deterministicRandomPicker(validIndices); - adventuresStats[indexToModify] += 1; - } - - return adventuresStats; -} - function floorAverage(stats: number[]): number { return Math.floor(stats.reduce((sum, stat) => sum + Math.round(stat), 0) / stats.length); } @@ -144,38 +50,13 @@ function convertStats(tacticsStats: ParseDataRangeCompleteness): ParseDataPerc { return advArrToObj(adventuresStats); } -function fixGlitchedSchimmering( - tacticsStatsObj: ParseDataRangeCompleteness, - adventuresStatsObj: ParseDataPerc -): ParseDataPerc { - const tacticsStats = tacticsStatsObjToArr(tacticsStatsObj); - const floorAvgGame1 = floorAverage(tacticsStats); - const adventuresStats = Object.values(adventuresStatsObj); - const isGlitched1 = tacticsStats.every((stat) => stat <= 5); - const isSchimmering1 = tacticsStats.every((stat) => stat >= 95); - const isGlitched2 = adventuresStats.every((stat) => stat <= 5); - const isSchimmering2 = adventuresStats.every((stat) => stat >= 95); - - let adventuresStatsCorrected; - if (isGlitched1 === isGlitched2 && isSchimmering1 === isSchimmering2) { - return adventuresStatsObj; - } else if (isGlitched1 !== isGlitched2) { - if (isGlitched1) { - adventuresStatsCorrected = makeGlitched(floorAvgGame1, adventuresStats); - } - adventuresStatsCorrected = removeGlitched(floorAvgGame1, adventuresStats); - } else if (isSchimmering1) adventuresStatsCorrected = makeSchimmering(floorAvgGame1, adventuresStats); - else adventuresStatsCorrected = removeSchimmering(floorAvgGame1, adventuresStats); - return advArrToObj(adventuresStatsCorrected); -} - export function getAdventuresStats(dnaSchemaReader: DNASchemaReader, adventuresStats: AdvStatsJSON): ParseDataAdv { const tacticsStats: Partial = {}; dnaSchemaReader.getCompletenessGenes().forEach((gene: GeneWithValues) => { tacticsStats[gene.name as keyof ParseDataRangeCompleteness] = Math.round((gene.completeness as number) * 100); }); const fixedStats = convertStats(tacticsStats as ParseDataRangeCompleteness); - const neftieName = TACTICS_ADV_NAMES_MAP[dnaSchemaReader.archetype.fixed_attributes.name]; + const neftieName = TACTICS_ADV_NAMES_MAP[dnaSchemaReader.archetype.fixed_attributes.name as NeftyCodeName]; const advStatsRanges = adventuresStats.nefties[neftieName]; Object.keys(fixedStats).forEach((key) => { const min = advStatsRanges[`${key}Min` as keyof AdvStatsJSONValue]; diff --git a/ts/src/constants.ts b/ts/src/constants.ts index 7923c06..396276f 100644 --- a/ts/src/constants.ts +++ b/ts/src/constants.ts @@ -3,7 +3,7 @@ export const GLITCHED_PERIOD = 1500; export const GLITCHED_RANGE_START = 5; export const SCHIMMERING_RANGE_START = 95; -export const TACTICS_ADV_NAMES_MAP: Record = { +export const TACTICS_ADV_NAMES_MAP = { Nefty_Bitebit: 'id_bitebit', Nefty_Dipking: 'id_dipking', Nefty_Dinobit: 'id_dinobit', @@ -29,4 +29,10 @@ export const TACTICS_ADV_NAMES_MAP: Record = { Nefty_Whiskube: 'id_whiskube', Nefty_Walpuff: 'id_walpuff', Nefty_Dinotusk: 'id_dinotusk', -}; +} as const; + +export const VERSION_LENGTH = 4; +export const LAST_SUPPORTED_VERSION_BY_V1 = '3.2.0'; + +// hp, atk, def, speed +export const N_STATS_SOT = 4; diff --git a/ts/src/deps/nefties_info.json b/ts/src/deps/nefties_info.json index 9157d49..daec3d7 100644 --- a/ts/src/deps/nefties_info.json +++ b/ts/src/deps/nefties_info.json @@ -30,9 +30,9 @@ "Bitebit": "Bitebit may look cuddly, but don't be fooled by its charm - those aren't paper hands... they're DIAMOND CLAWS!", "Dipking": "Dipking is bursting with personality and magical power, but handle with care - all that pent up energy may have EXPLOSIVE results!", "Dinobit": "Dinobit is big, strong and tough, but careful of that temper - Make it mad and watch out for a charge that can PLOW THROUGH EVERYTHING in its path!", - "Shiba": "Shiba Ignite is always ready to jump into the heat of battle, but while it may not be the fastest - a helping paw is always there to DEFEND ITS ALLIES.", + "ShibaIgnite": "Shiba Ignite is always ready to jump into the heat of battle, but while it may not be the fastest - a helping paw is always there to DEFEND ITS ALLIES.", "Zzoo": "Zzoo has a big beak and a bad attitude, but if it's on your side, both can be an asset - SWIFT STRIKES make its mean streak your advantage!", - "Blockchoy": "Block Choy may look tasty, but its real gift is far more delicious - a menu of healing powers is standing by to RE-FUEL its allies.", + "BlockChoy": "Block Choy may look tasty, but its real gift is far more delicious - a menu of healing powers is standing by to RE-FUEL its allies.", "Number9": "Number 9 is a solid choice despite appearances, but remember - it has to FLY THROUGH ENEMIES before it can attack them.", "Axobubble": "Axobubble is a born defender, but its magic is sneaky - buffs, curses and other mischief are sure to bubble up to HELP THE TEAM.", "Unika": "Unika is cool under pressure, but don't let its delicate appearance confuse you - it's ready with an ICE COLD STRIKE!", diff --git a/ts/src/deps/nefties_info_deprecated.json b/ts/src/deps/nefties_info_deprecated.json new file mode 100644 index 0000000..9157d49 --- /dev/null +++ b/ts/src/deps/nefties_info_deprecated.json @@ -0,0 +1,56 @@ +{ + "code_to_displayName": { + "Nefty_Bitebit": "Bitebit", + "Nefty_Dipking": "Dipking", + "Nefty_Dinobit": "Dinobit", + "Nefty_ShibaIgnite": "Shiba Ignite", + "Nefty_Zzoo": "Zzoo", + "Nefty_Blockchoy": "Block Choy", + "Nefty_Number9": "Number 9", + "Nefty_Axobubble": "Axobubble", + "Nefty_Unika": "Unika", + "Nefty_Chocomint": "Chocomint", + "Nefty_Cybertooth": "Cybertooth", + "Nefty_Wassie": "Wassie", + "Nefty_Dracurve": "Dracurve", + "Nefty_Raccoin": "Raccoin", + "Nefty_Shibark": "Shibark", + "Nefty_Unikirin": "Unikirin", + "Nefty_Beeblock": "Beeblock", + "Nefty_Chocorex": "Chocorex", + "Nefty_Keybab": "Keybab", + "Nefty_Bloomtail": "Bloomtail", + "Nefty_Tokoma": "Tokoma", + "Nefty_Ghouliath": "Ghouliath", + "Nefty_Whiskube": "Whiskube", + "Nefty_Walpuff": "Walpuff", + "Nefty_Dinotusk": "Dinotusk" + }, + "family_to_description": { + "Bitebit": "Bitebit may look cuddly, but don't be fooled by its charm - those aren't paper hands... they're DIAMOND CLAWS!", + "Dipking": "Dipking is bursting with personality and magical power, but handle with care - all that pent up energy may have EXPLOSIVE results!", + "Dinobit": "Dinobit is big, strong and tough, but careful of that temper - Make it mad and watch out for a charge that can PLOW THROUGH EVERYTHING in its path!", + "Shiba": "Shiba Ignite is always ready to jump into the heat of battle, but while it may not be the fastest - a helping paw is always there to DEFEND ITS ALLIES.", + "Zzoo": "Zzoo has a big beak and a bad attitude, but if it's on your side, both can be an asset - SWIFT STRIKES make its mean streak your advantage!", + "Blockchoy": "Block Choy may look tasty, but its real gift is far more delicious - a menu of healing powers is standing by to RE-FUEL its allies.", + "Number9": "Number 9 is a solid choice despite appearances, but remember - it has to FLY THROUGH ENEMIES before it can attack them.", + "Axobubble": "Axobubble is a born defender, but its magic is sneaky - buffs, curses and other mischief are sure to bubble up to HELP THE TEAM.", + "Unika": "Unika is cool under pressure, but don't let its delicate appearance confuse you - it's ready with an ICE COLD STRIKE!", + "Chocomint": "Chocomints are born with a remnant of their shells on their heads. To pass into adulthood, a joust between two young Chocomints takes place. The victor is the one who cracks its shell to unveil its cherry.", + "Cybertooth": "Cybertooth uses its thick hide to fortify its defenses, skillfully weakening foes with its abilities to gain an advantage and deal significant damage.", + "Wassie": "Wassie is a nimble Neftie known for harnessing its abilities to enhance its speed and inflict substantial damage.", + "Dracurve": "Dracurve dictates the course of battle with shifts in stats and status effects. Exercise caution, for a few strategic choices can swiftly pave the way to devastation.", + "Raccoin": "Raccoin's a sly, laid-back Neftie, known for its nimbleness and love for naps.", + "Shibark": "Shibark is known to roam vast landscapes, unearthing treasures and marking its territory with resounding howls.", + "Unikirin": "Each step Unikirin takes resonates with the crackle of electric energy, a testament to its wild and untamed spirit.", + "Beeblock": "Beeblock, known for its calm nature, is tamed for its production of a deliciously sweet jelly named Nury. But when threatened, it delivers powerful stings, warding off any attacker.", + "Chocorex": "Praised for its loyalty, Chocorex stands as a favored companion and trusted mount within Tokane. However, due to its limited intelligence, it requires skilled riders.", + "Keybab": "When activating its self-defense mechanism, Keybab changes color to boiling red, and its spicy vapor makes anyone who inhales it shed tears.", + "Bloomtail": "Bloomtail is a spirited Neftie fueled by the flower adorning its back. It is said that the flower occasionally affects its movements, adding an intriguing twist to its nature!", + "Tokoma": "Tokomas, the most loyal among Nefties, forms a lifelong bond and will protect its trainer at all costs!", + "Ghouliath": "Ghouliaths were mighty dragons once, but they now linger as ghosts until their unresolved matters are dealt with...", + "Whiskube": "Whiskubes love to rest on ice, but their reflective bodies cause them to melt it while sleeping. This leads to a sudden plunge into cold water, giving them quite the wake-up call!", + "Walpuff": "Walpuffs are known for their soft heads but sturdy bodies. That's why they wear protective headgear adorned with razor-sharp tusks, providing formidable armor!", + "Dinotusk": "Dinotusks use their unique tails to ground themselves to Tokane, generating energy and enabling survival in harsh environments." + } +} diff --git a/ts/src/deps/schemas/aurory_dna_v4.0.0.json b/ts/src/deps/schemas/aurory_dna_v4.0.0.json new file mode 100644 index 0000000..f28825d --- /dev/null +++ b/ts/src/deps/schemas/aurory_dna_v4.0.0.json @@ -0,0 +1,44 @@ +{ + "version": "4.0.0", + "version_date": "18/07/2024", + "global_genes_header": [ + { + "name": "version", + "base": 2 + } + ], + "archetypes": { + "1": "Nefty_Bitebit", + "2": "Nefty_Dipking", + "3": "Nefty_Dinobit", + "4": "Nefty_ShibaIgnite", + "5": "Nefty_Zzoo", + "6": "Nefty_Blockchoy", + "7": "Nefty_Number9", + "8": "Nefty_Axobubble", + "9": "Nefty_Unika", + "10": "Nefty_Chocomint", + "11": "Nefty_Cybertooth", + "12": "Nefty_Wassie", + "13": "Nefty_Dracurve", + "14": "Nefty_Raccoin", + "15": "Nefty_Shibark", + "16": "Nefty_Unikirin", + "17": "Nefty_Beeblock", + "18": "Nefty_Chocorex", + "19": "Nefty_Keybab", + "20": "Nefty_Bloomtail", + "21": "Nefty_Tokoma", + "22": "Nefty_Ghouliath", + "23": "Nefty_Whiskube", + "24": "Nefty_Walpuff", + "25": "Nefty_Dinotusk" + }, + "rarities": { + "0": "Common", + "1": "Uncommon", + "2": "Rare", + "3": "Epic", + "4": "Legendary" + } +} diff --git a/ts/src/deps/schemas/latest.ts b/ts/src/deps/schemas/latest.ts index c4bb975..c45cff9 100644 --- a/ts/src/deps/schemas/latest.ts +++ b/ts/src/deps/schemas/latest.ts @@ -1 +1 @@ -export const LATEST_VERSION = '3.2.0'; +export const LATEST_VERSION = '4.0.0'; diff --git a/ts/src/dna_factory.ts b/ts/src/dna_factory_v1.ts similarity index 97% rename from ts/src/dna_factory.ts rename to ts/src/dna_factory_v1.ts index 5203b39..8e9e66c 100644 --- a/ts/src/dna_factory.ts +++ b/ts/src/dna_factory_v1.ts @@ -34,11 +34,10 @@ import dnaSchemaV3_1 from './deps/schemas/aurory_dna_v3.1.0.json'; import dnaSchemaV3_2 from './deps/schemas/aurory_dna_v3.2.0.json'; import adventuresStatsV0_0_6 from './deps/schemas/adventures/v0.0.6.json'; import { LATEST_VERSION as LATEST_ADVENTURES_STATS_VERSION } from './deps/schemas/adventures/latest'; -import { LATEST_VERSION as LATEST_SCHEMA_VERSION } from './deps/schemas/latest'; import { LATEST_VERSION as LATEST_ABILTIIES_VERSION } from './deps/dictionaries/latest'; import abiltiesDictionaryV4 from './deps/dictionaries/abilities_dictionary_v0.4.0.json'; -import neftiesInfo from './deps/nefties_info.json'; import rarities from './deps/rarities.json'; +import neftiesInfo from './deps/nefties_info_deprecated.json'; import { DNA } from './dna'; import { getAverageFromRaw, @@ -50,6 +49,7 @@ import { } from './utils'; import { DNASchemaReader } from './dna_schema_reader'; import { getAdventuresStats } from './adventure_stats'; +import { LAST_SUPPORTED_VERSION_BY_V1 } from './constants'; const dnaSchemas: Record = { '0.2.0': dnaSchemaV0_2 as DNASchema, @@ -71,7 +71,7 @@ const abilitiesDictionaries: Record = { '0.4.0': abiltiesDictionaryV4 as AbilityDictionary, }; -export class DNAFactory { +export class DNAFactoryV1 { dnaSchemas: Record; abilitiesDictionary: Record; neftiesInfo: NeftiesInfo; @@ -89,7 +89,7 @@ export class DNAFactory { this.dnaBytes = dnaBytes ?? 64; this.encodingBase = encodingBase ?? 16; this.baseSize = this.encodingBase / 8; - this.latestSchemaVersion = LATEST_SCHEMA_VERSION; + this.latestSchemaVersion = LAST_SUPPORTED_VERSION_BY_V1; this.latestAbilitiesVersion = LATEST_ABILTIIES_VERSION; this.dnaSchemas = dnaSchemas; this.abilitiesDictionary = abilitiesDictionaries; @@ -275,7 +275,7 @@ export class DNAFactory { rarityPreset?: Rarity ) { const rarity = rarityPreset ?? this._getRandomRarity(grade); - const rarityIndex = Object.entries(dnaSchema.rarities).find(([_, rarityName]) => rarityName === rarity)?.[0]; + const rarityIndex = Object.entries(dnaSchema.rarities).find(([, rarityName]) => rarityName === rarity)?.[0]; if (!rarityIndex) throw new Error('Rarity not found'); const categoryKey = getCategoryKeyFromName('nefties', dnaSchema.categories); @@ -344,7 +344,7 @@ export class DNAFactory { `Archetype index not found. archetypeIndex ${archetypeIndex} schemaVersion ${schemaVersion} categoryKey ${categoryKey}` ); const rarity = 'Uncommon'; - const rarityIndex = Object.entries(dnaSchema.rarities).find(([_, rarityName]) => rarityName === rarity)?.[0]; + const rarityIndex = Object.entries(dnaSchema.rarities).find(([, rarityName]) => rarityName === rarity)?.[0]; if (!rarityIndex) throw new Error('Rarity not found'); const versionGeneInfo = dnaSchema.global_genes_header.find((gene_header) => gene_header.name === 'version'); @@ -393,7 +393,7 @@ export class DNAFactory { const abilityKeywords = this.getAbilitiesDictionary(version ?? this.latestAbilitiesVersion).keywords; const info = {} as AbilityInfo; for (const keyword in abilityKeywords) { - const [_, abilityName, infoType] = keyword.split('.'); + const [, abilityName, infoType] = keyword.split('.'); if (abilityName !== ability) continue; info[infoType as keyof AbilityInfo] = abilityKeywords[keyword as KeywordsKey]; if (info.name && info.description) return info; @@ -414,7 +414,7 @@ export class DNAFactory { * @param statsAverage average of all stats, from 0 to 100; */ getRarityFromStatsAvg(statsAverage: number, raiseErrorOnNotFound = true, grade: Grade = 'prime'): Rarity | null { - const rarity = Object.entries(this.rarities[grade]).find(([rarity, rarityInfo]) => { + const rarity = Object.entries(this.rarities[grade]).find(([, rarityInfo]) => { return ( statsAverage >= rarityInfo.average_stats_range[0] && ((statsAverage === 100 && statsAverage === rarityInfo.average_stats_range[1]) || @@ -454,7 +454,7 @@ export class DNAFactory { const genes = dnaSchema.categories[dnaSchemaReader.categoryKey].genes; const data: ParseData = Object.assign({} as ParseData, archetype.fixed_attributes); this._setRarity(data, dnaSchemaReader, dnaSchema); - this._setGrade(data, dnaSchemaReader, dnaSchema); + this._setGrade(data, dnaSchemaReader); const neftyNameCode = archetype.fixed_attributes.name as string; data['displayName'] = this.getDisplayNameFromCodeName(neftyNameCode); data['description'] = this.getFamilyDescription(archetype.fixed_attributes.family as string); @@ -506,7 +506,7 @@ export class DNAFactory { } } - private _setGrade(data: ParseData, dnaSchemaReader: DNASchemaReader, dnaSchema: DNASchema) { + private _setGrade(data: ParseData, dnaSchemaReader: DNASchemaReader) { if (dnaSchemaReader.categoryGenesHeader.grade) { // grade needs to be added to the dna generation process to support this // data.grade = (dnaSchema as DNASchemaV3).grades[dnaSchemaReader.categoryGenesHeader.grade]; diff --git a/ts/src/dna_factory_v2.ts b/ts/src/dna_factory_v2.ts new file mode 100644 index 0000000..67a96ca --- /dev/null +++ b/ts/src/dna_factory_v2.ts @@ -0,0 +1,312 @@ +import { + Grade, + Rarity, + version, + DNASchemaV4, + DnaDataV2, + ParseDataPerc, + NeftyCodeName, + ParseV2, + DnaData, + ParseDataComputed, + AdvStatsJSON, + AdvStatsJSONValue, + Parse, +} from './interfaces/types'; +import { LATEST_VERSION as LATEST_SCHEMA_VERSION } from './deps/schemas/latest'; +import { LATEST_VERSION as LATEST_ADVENTURES_STATS_VERSION } from './deps/schemas/adventures/latest'; +import dnaSchemaV4_0 from './deps/schemas/aurory_dna_v4.0.0.json'; +import { getAverageFromRaw, getLatestSubversion, randomInt, randomNormal, toPaddedHexa } from './utils'; +import { N_STATS_SOT, TACTICS_ADV_NAMES_MAP, VERSION_LENGTH } from './constants'; +import { DNAFactoryV1 } from './dna_factory_v1'; +import adventuresStatsV0_0_6 from './deps/schemas/adventures/v0.0.6.json'; +import { compressToBase64, decompressFromBase64 } from 'lz-string'; +import neftiesInfo from './deps/nefties_info.json'; +import raritiesJson from './deps/rarities.json'; + +const dnaSchemas: Record = { + '4.0.0': dnaSchemaV4_0 as DNASchemaV4, +}; + +const adventuresStats: Record = { + '0.0.6': adventuresStatsV0_0_6, +}; + +export class DNAFactoryV2 { + private latestSchemasSubversions: Record; + private codeNameToKey: Record; + constructor() { + this.latestSchemasSubversions = {}; + this.codeNameToKey = {} as Record; + Object.entries(this.getDNASchema(LATEST_SCHEMA_VERSION).archetypes).forEach(([key, codename]) => { + this.codeNameToKey[codename as NeftyCodeName] = key; + }); + } + + // get latest minor version from a major version + private getLatestSchemaSubversion(schemaVersion?: string): string { + if (!schemaVersion) return LATEST_SCHEMA_VERSION; + else if (schemaVersion?.includes('.')) return schemaVersion; + else if (schemaVersion && this.latestSchemasSubversions[schemaVersion]) + return this.latestSchemasSubversions[schemaVersion]; + const completeVersion = getLatestSubversion(dnaSchemas, schemaVersion); + this.latestSchemasSubversions[schemaVersion] = completeVersion; + return completeVersion; + } + + private _getRandomRarity(grade: Grade): Rarity { + const rarities = raritiesJson[grade]; + if (!rarities) { + throw new Error(`No rarity found for input ${grade}`); + } + const precision = 3; + const multiplier = Math.pow(10, precision); + const weightsSum = Object.values(rarities).reduce((acc, rarity) => acc + rarity.probability * multiplier, 0); + const random = Math.random() * weightsSum; + let total = 0; + for (const [rarity, rarityInfo] of Object.entries(rarities)) { + total += rarityInfo.probability * multiplier; + if (random <= total) return rarity as Rarity; + } + throw new Error(`No rarity found: ${weightsSum}, ${random}`); + } + + getDNASchema(version?: version): DNASchemaV4 { + if (!version) return dnaSchemas[LATEST_SCHEMA_VERSION]; + else if (dnaSchemas[version]) return dnaSchemas[version]; + else if (this.latestSchemasSubversions[version]) return dnaSchemas[this.latestSchemasSubversions[version]]; + + const completeVersion = version.includes('.') ? version : this.getLatestSchemaSubversion(version); + + if (!completeVersion) throw new Error(`No schema found for ${version}`); + + const dnaSchema: DNASchemaV4 = dnaSchemas[completeVersion]; + if (completeVersion !== dnaSchema.version) + throw new Error(`Versions mismatch: ${completeVersion} (filename) vs ${dnaSchema.version} (schema)`); + return dnaSchemas[completeVersion]; + } + + private serializeDna(data: DnaDataV2): string { + return compressToBase64(JSON.stringify(data)); + } + + private deserializeDna(dna: string): DnaDataV2 { + return JSON.parse(decompressFromBase64(dna)) as DnaDataV2; + } + + private getDna(version: version, data: DnaDataV2): string { + const versionDNAFormat = toPaddedHexa(version, 4); + + const serializedData = this.serializeDna(data); + const dna = versionDNAFormat + serializedData; + return dna; + } + + /** + * Generate statsCount number of stats with a mean value in the rarity range. + * @param rarity Rarity + * @param statsCount Number of stats to generate + */ + private _generateStatsForRarity(nStats: number, grade: Grade, rarity: Rarity): number[] { + const [minStatAvg, maxStatAvg] = raritiesJson[grade][rarity].average_stats_range; + const stats = Array.from(Array(nStats).keys()).map(() => 0); + + const mean = randomInt(minStatAvg, maxStatAvg, true); + const totalPoints = mean * nStats; + const maxValuePerStat = 100; + + // As 100 is the upper limit, this value is over represented in the distribution + // This makes the max random stat randomly set to a value between mean + 1 and 100 + const maxStatValue = randomInt(Math.min(mean + 1, maxValuePerStat), maxValuePerStat, false); + + const distributePoints = () => { + while (pointsLeft) { + const statIndex = randomInt(0, stats.length, true); + const statValue = stats[statIndex]; + + const maxPoints = Math.min(pointsLeft, maxStatValue - statValue); + if (!maxPoints) continue; + if (pointsLeft < 0) throw new Error('pointsLeft < 0'); + const points = randomNormal(1, Math.ceil(maxPoints / stats.length), -100, 200); + stats[statIndex] += points; + pointsLeft -= points; + } + }; + + let pointsLeft = totalPoints; + let raw = [] as number[]; + let average; + + while (pointsLeft) { + distributePoints(); + raw = stats.map((stat) => Math.round((stat / 100) * maxValuePerStat)); + + average = Math.floor( + getAverageFromRaw( + raw, + stats.map(() => maxValuePerStat) + ) * 100 + ); + + // the average is done on raw stats but points are distributed on the % stats. It may happen the means are not the same. + // adding up to "number of stats of used to compute the average" will still result in the same mean as we are rounding down + if (Math.floor(average) !== mean) pointsLeft += 1; + } + + // if average = 1 for a non glitched or 95 for a schimmering, we may end up not enterring in the previous loop + if (!raw.length) raw = stats.map((stat) => Math.round((stat / 100) * maxValuePerStat)); + return raw; + } + + private computeSOTStats(neftyCodeName: NeftyCodeName, dataAdv: ParseDataPerc): ParseDataComputed { + const neftyCodenameId = TACTICS_ADV_NAMES_MAP[neftyCodeName]; + const computed = {} as ParseDataComputed; + const sotStatsCurrent = adventuresStats[LATEST_ADVENTURES_STATS_VERSION].nefties[neftyCodenameId]; + if (!sotStatsCurrent) { + throw new Error(`No SOT stats found for ${neftyCodenameId}`); + } + Object.entries(dataAdv).forEach(([statName, percentage]) => { + const key = `${statName}Computed` as keyof ParseDataComputed; + const minK = `${statName}Min` as keyof AdvStatsJSONValue; + const min = sotStatsCurrent[minK]; + const maxK = `${statName}Max` as keyof AdvStatsJSONValue; + const max = sotStatsCurrent[maxK]; + const value = (percentage / 100) * (max - min) + min; + computed[key] = Math.round(value); + }); + return computed; + } + + private validateArchetypeIndex(archetypeIndex: string) { + if (!Number.isInteger(parseInt(archetypeIndex))) { + throw new Error(`Invalid archetype index: ${archetypeIndex}`); + } + } + + private createDnaData(dnaSchema: DNASchemaV4, archetypeIndex: string, grade: Grade, rarityPreset?: Rarity): DnaData { + const dnaData = { + grade, + rarity: rarityPreset ?? this._getRandomRarity(grade), + neftyCodeName: dnaSchema.archetypes[archetypeIndex] as NeftyCodeName, + }; + if (!dnaData.neftyCodeName) { + throw new Error(`No archetype found for index ${archetypeIndex}`); + } + return dnaData; + } + + private createDnaDataFromV1(dataV1: Parse): DnaData { + const dataData = {} as DnaData; + dataData.grade = dataV1.data.grade; + dataData.rarity = dataV1.data.rarity; + dataData.neftyCodeName = dataV1.archetype.fixed_attributes.name as NeftyCodeName; + return dataData; + } + + private createDataAdv(stats: number[]): ParseDataPerc { + const [hp, atk, def, speed] = stats; + return { + hp, + atk, + def, + speed, + }; + } + + private createDataAdvFromV1(stats: ParseDataPerc): ParseDataPerc { + const { hp, atk, def, speed } = stats; + return { + hp, + atk, + def, + speed, + }; + } + + getArchetypeKeyByNeftyCodeName(neftyCodeName: NeftyCodeName): string { + const archetypeKey = this.codeNameToKey[neftyCodeName]; + if (!archetypeKey) { + throw new Error(`No archetype found for ${neftyCodeName}`); + } + return archetypeKey; + } + + /** + * Returns rarity from stats average + * @param statsAverage average of all stats, from 0 to 100; + */ + getRarityFromStatsAvg(statsAverage: number, raiseErrorOnNotFound = true, grade: Grade): Rarity | null { + const rarity = Object.entries(raritiesJson[grade]).find(([, rarityInfo]) => { + return ( + statsAverage >= rarityInfo.average_stats_range[0] && + ((statsAverage === 100 && statsAverage === rarityInfo.average_stats_range[1]) || + statsAverage < rarityInfo.average_stats_range[1]) + ); + }); + if (!rarity) { + if (raiseErrorOnNotFound) throw new Error(`Rarity not found for stats average ${statsAverage}`); + else return null; + } + return rarity[0] as Rarity; + } + + parse(dnaString: string): ParseV2 { + const dataRaw = this.deserializeDna(dnaString.slice(VERSION_LENGTH)); + const dataAdv = Object.assign( + {}, + dataRaw.dataAdv, + this.computeSOTStats(dataRaw.data.neftyCodeName, dataRaw.dataAdv) + ); + const displayName = neftiesInfo.code_to_displayName[dataRaw.data.neftyCodeName]; + const data = Object.assign(dataRaw.data, { displayName }); + const parsed: ParseV2 = Object.assign({ version: dataRaw.version }, { dataAdv, dataRaw, data }); + return parsed; + } + + generateNeftyDNA(archetypeIndex: string, grade: Grade, forcedVersion?: string, rarityPreset?: Rarity) { + this.validateArchetypeIndex(archetypeIndex); + const dnaSchema = this.getDNASchema(forcedVersion ?? LATEST_SCHEMA_VERSION); + + const version = dnaSchema.version; + const data = Object.assign({}, this.createDnaData(dnaSchema, archetypeIndex, grade, rarityPreset)); + + const stats = this._generateStatsForRarity(N_STATS_SOT, grade, data.rarity); + const dataAdv = this.createDataAdv(stats); + const dnaData = Object.assign({}, { version, data, dataAdv }); + + return this.getDna(dnaSchema.version, dnaData); + } + + generateStarterNeftyDNA(archetypeIndex: string, forcedVersion?: string) { + this.validateArchetypeIndex(archetypeIndex); + const dnaSchema = this.getDNASchema(forcedVersion ?? LATEST_SCHEMA_VERSION); + const version = dnaSchema.version; + + const data = this.createDnaData(dnaSchema, archetypeIndex, 'standard', 'Uncommon'); + + const stats = Array(N_STATS_SOT).fill(30); + const dataAdv = this.createDataAdv(stats); + const dnaData = Object.assign({}, { version, data, dataAdv }); + + return this.getDna(dnaSchema.version, dnaData); + } + + generateNeftyDNAFromV1Dna( + dnaFactoryV1: DNAFactoryV1, + v1Dna: string, + newSotStats?: ParseDataPerc, + newVersion?: string + ) { + const dataV1 = dnaFactoryV1.parse(v1Dna); + const dnaSchema = this.getDNASchema(newVersion ?? LATEST_SCHEMA_VERSION); + const version = dnaSchema.version; + + const data = this.createDnaDataFromV1(dataV1); + + const stats = newSotStats ?? dataV1.dataAdv; + const dataAdv = this.createDataAdvFromV1(stats); + const dnaData = Object.assign({}, { version, data, dataAdv }); + + return this.getDna(dnaSchema.version, dnaData); + } +} diff --git a/ts/src/eggs_factory.ts b/ts/src/eggs_factory_v1.ts similarity index 78% rename from ts/src/eggs_factory.ts rename to ts/src/eggs_factory_v1.ts index f822063..d9e2058 100644 --- a/ts/src/eggs_factory.ts +++ b/ts/src/eggs_factory_v1.ts @@ -1,9 +1,10 @@ import { EggInfo, Archetype, DroppableNeftyInfo } from './interfaces/types'; import eggsInfo from './deps/eggs_info.json'; import standardEggsInfo from './deps/standard_eggs_info.json'; -import { DNAFactory } from './dna_factory'; +import { DNAFactoryV1 as DNAFactory } from './dna_factory_v1'; +import { LAST_SUPPORTED_VERSION_BY_V1 } from './constants'; -export class EggsFactory { +export class EggsFactoryV1 { eggInfo: EggInfo; standardEggInfo: EggInfo; df: DNAFactory; @@ -14,17 +15,17 @@ export class EggsFactory { } static getAllEggs(): Record { - return eggsInfo; + return eggsInfo as Record; } static getAllStandardEggs(): Record { - return standardEggsInfo; + return standardEggsInfo as Record; } getDroppableNefties(): DroppableNeftyInfo[] { return this.eggInfo.archetypes.map((neftyCodeName) => { const r: DroppableNeftyInfo = {} as DroppableNeftyInfo; - Object.assign(r, this.df.getArchetypeByNeftyCodeName(neftyCodeName)); + Object.assign(r, this.df.getArchetypeByNeftyCodeName(neftyCodeName, LAST_SUPPORTED_VERSION_BY_V1)); r.displayName = this.df.getDisplayNameFromCodeName(neftyCodeName); r.description = this.df.getFamilyDescription(r.archetype.fixed_attributes.family as string); return r; @@ -34,7 +35,7 @@ export class EggsFactory { getDroppableStandardNefties(): DroppableNeftyInfo[] { return this.standardEggInfo.archetypes.map((neftyCodeName) => { const r: DroppableNeftyInfo = {} as DroppableNeftyInfo; - Object.assign(r, this.df.getArchetypeByNeftyCodeName(neftyCodeName)); + Object.assign(r, this.df.getArchetypeByNeftyCodeName(neftyCodeName, LAST_SUPPORTED_VERSION_BY_V1)); r.displayName = this.df.getDisplayNameFromCodeName(neftyCodeName); r.description = this.df.getFamilyDescription(r.archetype.fixed_attributes.family as string); return r; @@ -44,12 +45,12 @@ export class EggsFactory { hatch(): { archetypeKey: string; archetype: Archetype } { const droppableArchetypes = this.eggInfo.archetypes; const neftyCodeName = droppableArchetypes[Math.floor(Math.random() * droppableArchetypes.length)]; - return this.df.getArchetypeByNeftyCodeName(neftyCodeName); + return this.df.getArchetypeByNeftyCodeName(neftyCodeName, LAST_SUPPORTED_VERSION_BY_V1); } hatchStandard(): { archetypeKey: string; archetype: Archetype } { const droppableArchetypes = this.standardEggInfo.archetypes; const neftyCodeName = droppableArchetypes[Math.floor(Math.random() * droppableArchetypes.length)]; - return this.df.getArchetypeByNeftyCodeName(neftyCodeName); + return this.df.getArchetypeByNeftyCodeName(neftyCodeName, LAST_SUPPORTED_VERSION_BY_V1); } } diff --git a/ts/src/eggs_factory_v2.ts b/ts/src/eggs_factory_v2.ts new file mode 100644 index 0000000..aac866e --- /dev/null +++ b/ts/src/eggs_factory_v2.ts @@ -0,0 +1,56 @@ +import { EggInfo, DroppableNeftyInfoV2, NeftyCodeName } from './interfaces/types'; +import eggsInfo from './deps/eggs_info.json'; +import standardEggsInfo from './deps/standard_eggs_info.json'; +import { DNAFactoryV2 as DNAFactory } from './dna_factory_v2'; +import neftiesInfo from './deps/nefties_info.json'; + +export class EggsFactoryV2 { + eggInfo: EggInfo; + standardEggInfo: EggInfo; + df: DNAFactory; + constructor(eggPk: string, df: DNAFactory) { + this.eggInfo = (eggsInfo as Record)[eggPk]; + this.standardEggInfo = (standardEggsInfo as Record)[eggPk]; + this.df = df; + } + + static getAllEggs(): Record { + return eggsInfo as Record; + } + + static getAllStandardEggs(): Record { + return standardEggsInfo as Record; + } + + getDroppableNefties(): DroppableNeftyInfoV2[] { + return this.eggInfo.archetypes.map((neftyCodeName) => { + const r = {} as DroppableNeftyInfoV2; + r.neftyCodeName = neftyCodeName; + r.displayName = neftiesInfo.code_to_displayName[neftyCodeName] as string; + return r; + }); + } + + getDroppableStandardNefties(): DroppableNeftyInfoV2[] { + return this.standardEggInfo.archetypes.map((neftyCodeName) => { + const r = {} as DroppableNeftyInfoV2; + r.neftyCodeName = neftyCodeName; + r.displayName = neftiesInfo.code_to_displayName[neftyCodeName] as string; + return r; + }); + } + + hatch(): { archetypeKey: string; neftyCodeName: NeftyCodeName } { + const droppableArchetypes = this.eggInfo.archetypes; + const neftyCodeName = droppableArchetypes[Math.floor(Math.random() * droppableArchetypes.length)]; + const archetypeKey = this.df.getArchetypeKeyByNeftyCodeName(neftyCodeName); + return { archetypeKey, neftyCodeName }; + } + + hatchStandard(): { archetypeKey: string; neftyCodeName: NeftyCodeName } { + const droppableArchetypes = this.standardEggInfo.archetypes; + const neftyCodeName = droppableArchetypes[Math.floor(Math.random() * droppableArchetypes.length)]; + const archetypeKey = this.df.getArchetypeKeyByNeftyCodeName(neftyCodeName); + return { archetypeKey, neftyCodeName }; + } +} diff --git a/ts/src/index.ts b/ts/src/index.ts index 4a24fc3..7c61e79 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -1,5 +1,12 @@ -export * from './dna_factory'; -export * from './eggs_factory'; +import { DNAFactoryV2 as DNAFactory } from './dna_factory_v2'; +export { DNAFactory }; +import { EggsFactoryV2 as EggsFactory } from './eggs_factory_v2'; +export { EggsFactory }; + +export * from './dna_factory_v1'; +export * from './dna_factory_v2'; +export * from './eggs_factory_v1'; +export * from './eggs_factory_v2'; export * from './dna_schema_reader'; export * from './interfaces/types'; export * from './constants'; diff --git a/ts/src/interfaces/types.ts b/ts/src/interfaces/types.ts index 1cd9cb5..c474f88 100644 --- a/ts/src/interfaces/types.ts +++ b/ts/src/interfaces/types.ts @@ -1,4 +1,5 @@ import abiltiesDictionaryV4 from '../deps/dictionaries/abilities_dictionary_v0.4.0.json'; +import { TACTICS_ADV_NAMES_MAP } from '../constants'; export type GeneType = 'index' | 'range_completeness'; @@ -56,6 +57,19 @@ export interface DNASchemaV2 { export type DNASchemaV3 = DNASchemaV2; +// eg: Nefty_Bitebit +export type NeftyCodeName = keyof typeof TACTICS_ADV_NAMES_MAP; +// eg: id_bitebit +export type NeftyCodeNameId = typeof TACTICS_ADV_NAMES_MAP[keyof typeof TACTICS_ADV_NAMES_MAP]; + +export interface DNASchemaV4 { + version: string; + version_date: string; + global_genes_header: Gene[]; + archetypes: Record; + rarities: Record; +} + export type DNASchema = DNASchemaV0 | DNASchemaV2 | DNASchemaV3; export type Rarity = 'Common' | 'Uncommon' | 'Rare' | 'Epic' | 'Legendary'; @@ -83,7 +97,7 @@ export interface ImageNeftyByGame { export type NeftyImageFormat = keyof ImageNeftyByGame; export interface ParseDataNefty { - name: string; + name: NeftyCodeName; displayName: string; family: string; passiveSkill: string; @@ -161,7 +175,6 @@ export interface Parse { metadata: { version: string }; genes: Gene[]; } - export interface AbilityLocalizedValue { EN: string; FR: string; @@ -188,11 +201,8 @@ export interface NeftiesInfo { family_to_description: Record; } -// eg: Nefty_Bitebit -type NeftyCodeName = string; - export interface EggInfo { - name: string; + name: NeftyCodeName; description: string; archetypes: NeftyCodeName[]; } @@ -204,6 +214,11 @@ export interface DroppableNeftyInfo { description: string; } +export interface DroppableNeftyInfoV2 { + neftyCodeName: NeftyCodeName; + displayName: string; +} + export type version = string; export type GeneWithValues = Gene & { @@ -212,3 +227,26 @@ export type GeneWithValues = Gene & { completeness?: number; skill_info?: AbilityInfo; }; + +export interface DnaData { + grade: Grade; + rarity: Rarity; + neftyCodeName: NeftyCodeName; +} + +export interface DnaDataParsed extends DnaData { + displayName: string; +} + +export interface DnaDataV2 { + version: string; + data: DnaData; + dataAdv: ParseDataPerc; +} + +export interface ParseV2 { + version: string; + dataRaw: DnaDataV2; + data: DnaDataParsed; + dataAdv: ParseDataAdv; +} diff --git a/ts/src/utils.ts b/ts/src/utils.ts index 3d018c9..6b255b6 100644 --- a/ts/src/utils.ts +++ b/ts/src/utils.ts @@ -1,4 +1,4 @@ -import { AbilityDictionary, Category, DNASchema } from './interfaces/types'; +import { AbilityDictionary, Category, DNASchema, DNASchemaV4 } from './interfaces/types'; // you should multiply by 100 to get in % export function getAverageFromRaw(numbers: number[], maxValuePerStat: number[]): number { @@ -23,7 +23,7 @@ export function getCategoryKeyFromName(name: string, categories: Record, + completeVersionsDict: Record, schemaVersionInput?: string ): string { const schemaVersion = schemaVersionInput @@ -87,3 +87,21 @@ export function randomNormal(min: number, max: number, leftLimit: number, rightL export function unpad(v: string, encodingBase: number | undefined): string { return parseInt(v, encodingBase).toString(); } + +export function toPadded(n: string, maxLength: number, radix?: number): string { + return parseInt(n).toString(radix).padStart(maxLength, '0'); +} + +/** + * Converts a string representation of a number to a hexadecimal string + * and pads it with leading zeros to ensure it has the specified length. + * @example + * _toPaddedHexa('255', 4); / Returns '00ff' + */ +export function toPaddedHexa(n: string, maxLength: number): string { + return toPadded(n, maxLength, 16); +} + +export function toUnPaddedHexa(n: string): string { + return unpad(n, 16); +} diff --git a/ts/tests/distribution.spec.ts b/ts/tests/distribution.spec.ts index 8dcbbe5..f9b55a7 100644 --- a/ts/tests/distribution.spec.ts +++ b/ts/tests/distribution.spec.ts @@ -1,4 +1,4 @@ -import { DNAFactory, EggsFactory, GLITCHED_PERIOD, Rarity, SCHIMMERING_PERIOD, utils } from '../src'; +import { DNAFactoryV1 as DNAFactory, EggsFactoryV1 as EggsFactory, Rarity } from '../src'; import assert from 'assert'; import rarities from '../src/deps/rarities.json'; @@ -22,7 +22,7 @@ describe('Distribution', () => { * then checks if the deviation from the expected distribution is less than 20%. */ it('Means are evenly distributed', (done) => { - const statMeans = {} as any; + const statMeans = {} as Record; const loopCount = 15000; let loopDone = 0; const workers = [] as { id: number; worker: Worker }[]; @@ -83,9 +83,9 @@ describe('Distribution', () => { const df = new DNAFactory(undefined, undefined); const ef = new EggsFactory('8XaR7cPaMZoMXWBWgeRcyjWRpKYpvGsPF6dMwxnV4nzK', df); const rarityStats = ['hp', 'initiative', 'atk', 'def', 'eatk', 'edef']; - const statsCount = {} as any; + const statsCount = {} as Record; const loopCount = 1000; - Object.entries(rarities.prime).forEach(([rarity, rarityInfo]) => { + Object.entries(rarities.prime).forEach(([rarity]) => { for (let i = 0; i < loopCount; i++) { const dna = df.generateNeftyDNA(ef.hatch().archetypeKey, 'prime', undefined, rarity as Rarity); const parsed = df.parse(dna); @@ -103,7 +103,7 @@ describe('Distribution', () => { // this may fail sometimes as we only do 300k iterations it('Rarity & Glitched & Schimmering distribution rates are within a specific range of the targets', (done) => { - const rarityCount: Record = {} as any; + const rarityCount = {} as Record; const loopCount = 300000; let loopDone = 0; const workers = [] as { id: number; worker: Worker }[]; diff --git a/ts/tests/dna.spec.ts b/ts/tests/dna.spec.ts index 285db63..b22c491 100644 --- a/ts/tests/dna.spec.ts +++ b/ts/tests/dna.spec.ts @@ -1,11 +1,10 @@ -import { DNAFactory, EggsFactory, Rarity, utils } from '../src'; +import { DNAFactoryV1 as DNAFactory, EggsFactoryV1 as EggsFactory, Rarity, utils } from '../src'; import nefties_info from '../src/deps/nefties_info.json'; import assert from 'assert'; import rarities from '../src/deps/rarities.json'; import { readdirSync, readFileSync } from 'fs'; -import { LATEST_VERSION as LATEST_SCHEMA_VERSION } from '../src/deps/schemas/latest'; -import { DNASchema } from '../src/interfaces/types'; -import { TACTICS_ADV_NAMES_MAP } from '../src/constants'; +import { DNASchema, NeftyCodeName } from '../src/interfaces/types'; +import { LAST_SUPPORTED_VERSION_BY_V1, TACTICS_ADV_NAMES_MAP } from '../src/constants'; const displayNamesProd = [ 'Axobubble', @@ -153,24 +152,27 @@ const abilitiesProd = new Set([ 'N/A', ]); +const LAST_FACTORY_V1_SUPPORTED_VERSION = '3.2.0'; + const allSchemaVersions = readdirSync('./src/deps/schemas') .filter((v) => v.endsWith('json')) .map((v) => { const index = v.indexOf('_v'); return v.slice(index + 2, index + 7); - }); + }) + .filter((v) => parseInt(v.split('.')[0]) <= 3); describe('Basic', () => { it('DNA should parse', () => { const df = new DNAFactory(); - Object.entries(EggsFactory.getAllEggs()).forEach(([eggPk, eggInfo]) => { + Object.entries(EggsFactory.getAllEggs()).forEach(([eggPk]) => { const ef = new EggsFactory(eggPk, df); const droppableNefties = ef.getDroppableNefties(); droppableNefties.forEach(({ archetypeKey, archetype, displayName, description }) => { try { assert.ok(displayName); assert.ok(description); - const dna = df.generateNeftyDNA(archetypeKey, 'prime'); + const dna = df.generateNeftyDNA(archetypeKey, 'prime', LAST_FACTORY_V1_SUPPORTED_VERSION); const data = df.parse(dna); assert.ok(data.data); assert.ok(data.data.name); @@ -182,11 +184,10 @@ describe('Basic', () => { assert.ok(data.data.description); assert.ok(data.data.rarity); assert.ok(data.data.defaultImage); - assert.ok(data.data.imageByGame); - assert.ok(data.data.imageByGame.tactics); - assert.ok(data.data.imageByGame.tactics.medium); - assert.ok(data.data.imageByGame.tactics.small); - assert.ok(data.data.element); + // assert.ok(data.data.imageByGame); + // assert.ok(data.data.imageByGame.tactics); + // assert.ok(data.data.imageByGame.tactics.medium); + // assert.ok(data.data.imageByGame.tactics.small); assert.ok(Number.isInteger(data.data.hp)); assert.ok(Number.isInteger(data.data.initiative)); assert.ok(Number.isInteger(data.data.atk)); @@ -277,11 +278,10 @@ describe('Using previous schema 0.2.0', () => { it('Parsed stats should reflect the schema parameter as an input', () => { const forceVersion = '0.2.0'; const df = new DNAFactory(undefined, undefined); - const ef = new EggsFactory('8XaR7cPaMZoMXWBWgeRcyjWRpKYpvGsPF6dMwxnV4nzK', df); assert.throws(() => { // 7 does not exist on schema 0.2.0 const dna = df.generateNeftyDNA('7', 'prime', forceVersion); - const p = df.parse(dna, forceVersion); + df.parse(dna, forceVersion); }); const dinobitArchetypeIndex = '2'; const dna = df.generateNeftyDNA(dinobitArchetypeIndex, 'prime', forceVersion); @@ -314,7 +314,7 @@ describe('Rarity', () => { const statsAvg = utils.getAverageFromRaw( rarityStats.map((v) => parsed.raw[v]), - rarityStats.map((v) => 255) + rarityStats.map(() => 255) ) * 100; assert.deepEqual(df.getRarityFromStatsAvg(statsAvg), rarity); assert.ok(statsAvg >= rarityInfo.average_stats_range[0]); @@ -336,11 +336,11 @@ describe('Rarity', () => { const statsAvg = utils.getAverageFromRaw( rarityStats.map((v) => parsed.raw[v]), - rarityStats.map((v) => 255) + rarityStats.map(() => 255) ) * 100; assert.deepEqual(df.getRarityFromStatsAvg(statsAvg), rarity); assert.ok(statsAvg >= rarityInfo.average_stats_range[0]); - if (statsAvg === 100) assert.ok(statsAvg === rarityInfo.average_stats_range[1]); + if (statsAvg === 100) assert.ok(statsAvg === rarityInfo.average_stats_range[1] - 1); else assert.ok(statsAvg < rarityInfo.average_stats_range[1]); // } }); @@ -350,11 +350,12 @@ describe('Rarity', () => { describe('Adventures', () => { it('All archetypes have adventure stats', () => { const schema: DNASchema = JSON.parse( - readFileSync(`./src/deps/schemas/aurory_dna_v${LATEST_SCHEMA_VERSION}.json`, 'utf8') + readFileSync(`./src/deps/schemas/aurory_dna_v${LAST_SUPPORTED_VERSION_BY_V1}.json`, 'utf8') ); + Object.values(schema.categories['0'].archetypes).forEach((archetype) => { assert.ok( - TACTICS_ADV_NAMES_MAP[archetype.fixed_attributes.name], + TACTICS_ADV_NAMES_MAP[archetype.fixed_attributes.name as NeftyCodeName], `${archetype.fixed_attributes.name} not found in TACTICS_ADV_NAMES_MAP: ${JSON.stringify( TACTICS_ADV_NAMES_MAP )}` diff --git a/ts/tests/dna.v2.spec.ts b/ts/tests/dna.v2.spec.ts new file mode 100644 index 0000000..4524e1f --- /dev/null +++ b/ts/tests/dna.v2.spec.ts @@ -0,0 +1,218 @@ +import { DNAFactory, DNAFactoryV1, EggsFactory, Rarity, utils } from '../src'; +import nefties_info from '../src/deps/nefties_info.json'; +import assert from 'assert'; +import { readdirSync } from 'fs'; +import { ParseDataPerc } from '../src/interfaces/types'; +import { TACTICS_ADV_NAMES_MAP } from '../src/constants'; +import raritiesJson from '../src/deps/rarities.json'; +import standard_eggs_info from '../src/deps/standard_eggs_info.json'; + +const displayNamesProd = [ + 'Axobubble', + 'Bitebit', + 'Dipking', + 'Dinobit', + 'Shiba Ignite', + 'Zzoo', + 'Block Choy', + 'Number 9', + 'Unika', + 'Chocomint', + 'Cybertooth', + 'Wassie', + 'Dracurve', + 'Raccoin', + 'Shibark', + 'Unikirin', + 'Beeblock', + 'Chocorex', + 'Keybab', + 'Bloomtail', + 'Tokoma', + 'Ghouliath', + 'Whiskube', + 'Walpuff', + 'Dinotusk', +]; + +const allSchemaVersions = readdirSync('./src/deps/schemas') + .filter((v) => v.endsWith('json')) + .map((v) => { + const index = v.indexOf('_v'); + return v.slice(index + 2, index + 7); + }) + .filter((v) => parseInt(v.split('.')[0]) > 3); + +describe('Basic', () => { + it('DNA should parse', () => { + const df = new DNAFactory(); + Object.entries(EggsFactory.getAllEggs()).forEach(([eggPk]) => { + const ef = new EggsFactory(eggPk, df); + const droppableNefties = ef.getDroppableNefties(); + droppableNefties.forEach(({ neftyCodeName, displayName }) => { + assert.ok(displayName); + assert.ok(neftyCodeName); + assert.ok(TACTICS_ADV_NAMES_MAP[neftyCodeName]); + const archetypeKey = df.getArchetypeKeyByNeftyCodeName(neftyCodeName); + assert.ok(archetypeKey); + assert.ok(nefties_info.code_to_displayName[neftyCodeName]); + assert.ok( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (nefties_info.family_to_description as any)[ + nefties_info.code_to_displayName[neftyCodeName].replace(/\s+/g, '') + ], + `Family description not found for ${nefties_info.code_to_displayName[neftyCodeName].replace(/\s+/g, '')}` + ); + const dna = df.generateNeftyDNA(archetypeKey, 'prime'); + const data = df.parse(dna); + assert.ok(data.data); + assert.ok(data.data.grade); + assert.ok(data.data.neftyCodeName); + assert.ok(data.data.rarity); + assert.ok(data.dataAdv); + assert.ok(Number.isInteger(data.dataAdv.atk)); + assert.ok(Number.isInteger(data.dataAdv.def)); + assert.ok(Number.isInteger(data.dataAdv.hp)); + assert.ok(Number.isInteger(data.dataAdv.speed)); + assert.ok(Number.isInteger(data.dataAdv.atkComputed)); + assert.ok(Number.isInteger(data.dataAdv.defComputed)); + assert.ok(Number.isInteger(data.dataAdv.hpComputed)); + assert.ok(Number.isInteger(data.dataAdv.speedComputed)); + assert.ok(data.version); + }); + }); + }); + + // Display names are used to compute image URLs + it('Ensure display names never change', () => { + Object.values(nefties_info.code_to_displayName).forEach((displayName) => { + assert(displayNamesProd.includes(displayName), `${displayName} is not in displayNamesProd`); + }); + }); + + it('Generated Nefty DNA version matches forced version', () => { + const df = new DNAFactory(); + const ef = new EggsFactory('8XaR7cPaMZoMXWBWgeRcyjWRpKYpvGsPF6dMwxnV4nzK', df); + for (let index = 0; index < allSchemaVersions.length; index++) { + const version = allSchemaVersions[index]; + const majorVersion = version.split('.')[0]; + const dna = df.generateNeftyDNA(ef.hatch().archetypeKey, 'prime', version); + const parsed = df.parse(dna); + const parsedMajorVersion = parsed.version.split('.')[0]; + assert.equal(majorVersion, parsedMajorVersion); + } + }); +}); + +describe('Rarity', () => { + it('Rarity matches the average stats for latest version', () => { + const df = new DNAFactory(); + const ef = new EggsFactory('8XaR7cPaMZoMXWBWgeRcyjWRpKYpvGsPF6dMwxnV4nzK', df); + const rarityStats: (keyof ParseDataPerc)[] = ['hp', 'atk', 'def', 'speed']; + Object.entries(raritiesJson.prime).forEach(([rarity, rarityInfo]) => { + // for (let i = 0; i < 1e3; i++) { + const dna = df.generateNeftyDNA(ef.hatch().archetypeKey, 'prime', undefined, rarity as Rarity); + const parsed = df.parse(dna); + assert.deepEqual(parsed.data.rarity, rarity); + const statsAvg = + utils.getAverageFromRaw( + rarityStats.map((v) => parsed.dataAdv[v]), + rarityStats.map(() => 100) + ) * 100; + assert.deepEqual(df.getRarityFromStatsAvg(statsAvg, true, 'prime'), rarity); + assert.ok(statsAvg >= rarityInfo.average_stats_range[0]); + if (statsAvg === 100) assert.ok(statsAvg === rarityInfo.average_stats_range[1]); + else assert.ok(statsAvg < rarityInfo.average_stats_range[1]); + }); + }); +}); + +describe('starter eggs', () => { + it('Starter egg should be hatched with constant stats and rarity', () => { + const standardEggs = EggsFactory.getAllStandardEggs(); + const eggPk = Object.keys(standardEggs)[0]; + const df = new DNAFactory(); + const ef = new EggsFactory(eggPk, df); + for (let index = 0; index < 10; index++) { + const dna = df.generateStarterNeftyDNA(ef.hatchStandard().archetypeKey); + assert(dna); + const data = df.parse(dna); + const expectedRawStatValue = 30; + assert.equal(standard_eggs_info.starter_egg.archetypes.includes(data.data.neftyCodeName), true); + assert.equal(data.dataAdv.hp, expectedRawStatValue); + assert.equal(data.dataAdv.atk, expectedRawStatValue); + assert.equal(data.dataAdv.def, expectedRawStatValue); + assert.equal(data.dataAdv.speed, expectedRawStatValue); + } + }); +}); + +describe('standard eggs', () => { + it('all standard eggs should be hatchable', () => { + const df = new DNAFactory(); + const standardEggs = EggsFactory.getAllStandardEggs(); + Object.keys(standardEggs).forEach((eggName) => { + const ef = new EggsFactory(eggName, df); + const archetypeKey = ef.hatchStandard().archetypeKey; + if (eggName === 'starter_egg') { + const dna = df.generateStarterNeftyDNA(archetypeKey); + assert(dna); + const parsedDna = df.parse(dna); + assert.equal(standardEggs[eggName].archetypes.includes(parsedDna.data.neftyCodeName), true); + } else { + const dna = df.generateNeftyDNA(archetypeKey, 'standard'); + assert(dna); + const parsedDna = df.parse(dna); + assert.equal(standardEggs[eggName].archetypes.includes(parsedDna.data.neftyCodeName), true); + } + }); + }); +}); + +describe('droppable nefties', () => { + const df = new DNAFactory(); + it('all standard eggs archetypes are droppable', () => { + const standardEggs = EggsFactory.getAllStandardEggs(); + Object.keys(standardEggs).forEach((eggName) => { + const ef = new EggsFactory(eggName, df); + const droppableStandardNefties = ef.getDroppableStandardNefties(); + + assert(droppableStandardNefties); + assert.equal(droppableStandardNefties.length, standardEggs[eggName].archetypes.length); + }); + }); + it('all prime eggs neftie archetypes are droppable', () => { + const primeEggs = EggsFactory.getAllEggs(); + Object.keys(primeEggs).forEach((eggName) => { + const ef = new EggsFactory(eggName, df); + const droppableNefties = ef.getDroppableNefties(); + + assert(droppableNefties); + assert.equal(droppableNefties.length, primeEggs[eggName].archetypes.length); + }); + }); +}); + +describe('From V1 DNA', () => { + const df = new DNAFactory(); + const dfV1 = new DNAFactoryV1(); + const ef = new EggsFactory('8XaR7cPaMZoMXWBWgeRcyjWRpKYpvGsPF6dMwxnV4nzK', df); + it('From V1 DNA', () => { + const advStatKeys: (keyof ParseDataPerc)[] = ['hp', 'atk', 'def', 'speed']; + const dnaV1 = dfV1.generateNeftyDNA(ef.hatch().archetypeKey, 'prime'); + const parsedV1 = dfV1.parse(dnaV1); + const newStats = {} as Record; + advStatKeys.forEach((key) => { + newStats[key] = Math.min(parsedV1.dataAdv[key] + 1, 100); + }); + const newDna = df.generateNeftyDNAFromV1Dna(dfV1, dnaV1, newStats); + const newParsed = df.parse(newDna); + assert.deepEqual(newParsed.dataAdv.hp, newStats.hp); + assert.deepEqual(newParsed.dataAdv.atk, newStats.atk); + assert.deepEqual(newParsed.dataAdv.def, newStats.def); + assert.deepEqual(newParsed.dataAdv.speed, newStats.speed); + assert.deepEqual(newParsed.data.rarity, parsedV1.data.rarity); + assert.deepEqual(newParsed.data.displayName, parsedV1.data.displayName); + assert.deepEqual(newParsed.data.neftyCodeName, parsedV1.archetype.fixed_attributes.name); + }); +}); diff --git a/ts/tests/workers/distribution-worker.ts b/ts/tests/workers/distribution-worker.ts index 8d299a6..d83dc80 100644 --- a/ts/tests/workers/distribution-worker.ts +++ b/ts/tests/workers/distribution-worker.ts @@ -1,6 +1,6 @@ import { parentPort, workerData } from 'worker_threads'; -import { DNAFactory } from '../../src/dna_factory'; -import { EggsFactory } from '../../src/eggs_factory'; +import { DNAFactoryV1 as DNAFactory } from '../../src/dna_factory_v1'; +import { EggsFactoryV1 as EggsFactory } from '../../src/eggs_factory_v1'; import { Rarity } from '../../src/interfaces/types'; import { utils } from '../../src'; @@ -16,14 +16,14 @@ async function run(loopCount: number) { const rarityStats = ['hp', 'initiative', 'atk', 'def', 'eatk', 'edef']; let glitchedCount = 0; let schimmeringCount = 0; - const rarityCount: Record = {} as any; + const rarityCount = {} as Record; for (let i = 0; i < loopCount; i++) { const dna = df.generateNeftyDNA(ef.hatch().archetypeKey, 'prime'); const parsed = df.parse(dna); const statsAvg = utils.getAverageFromRaw( rarityStats.map((v) => parsed.raw[v]), - rarityStats.map((v) => 255) + rarityStats.map(() => 255) ) * 100; if (statsAvg < 6) { const stats = rarityStats.map((v) => Math.round((parsed.raw[v] / 255) * 100)); @@ -36,7 +36,9 @@ async function run(loopCount: number) { schimmeringCount += 1; } } - const rarity = df.getRarityFromStatsAvg(statsAvg, true, 'prime')!; + const rarity = df.getRarityFromStatsAvg(statsAvg, true); + if (!rarity) throw new Error('Rarity not found'); + if (rarityCount[rarity]) rarityCount[rarity] += 1; else rarityCount[rarity] = 1; } diff --git a/ts/tests/workers/stats-mean-worker.ts b/ts/tests/workers/stats-mean-worker.ts index b014eb8..7deb7bb 100644 --- a/ts/tests/workers/stats-mean-worker.ts +++ b/ts/tests/workers/stats-mean-worker.ts @@ -1,9 +1,9 @@ import { parentPort, workerData } from 'worker_threads'; -import { DNAFactory } from '../../src/dna_factory'; -import { EggsFactory } from '../../src/eggs_factory'; -import { Rarity } from '../../src/interfaces/types'; -import { utils } from '../../src'; +import { DNAFactoryV1 as DNAFactory } from '../../src/dna_factory_v1'; +import { EggsFactoryV1 as EggsFactory } from '../../src/eggs_factory_v1'; +import { Grade, Rarity } from '../../src/interfaces/types'; import rarities from '../../src/deps/rarities.json'; +import { utils } from '../../src/'; export interface StatsMeanWorker { statMeans: Record; @@ -14,7 +14,7 @@ async function run(loopCount: number): Promise { const df = new DNAFactory(undefined, undefined); const ef = new EggsFactory('8XaR7cPaMZoMXWBWgeRcyjWRpKYpvGsPF6dMwxnV4nzK', df); const rarityStats = ['hp', 'initiative', 'atk', 'def', 'eatk', 'edef']; - const statMeans = {} as any; + const statMeans = {} as Record; Object.entries(rarities.prime).forEach(([rarity]) => { for (let i = 0; i < loopCount; i++) { const dna = df.generateNeftyDNA(ef.hatch().archetypeKey, 'prime', undefined, rarity as Rarity); @@ -22,13 +22,13 @@ async function run(loopCount: number): Promise { const statsMean = Math.floor( utils.getAverageFromRaw( rarityStats.map((v) => parsed.raw[v]), - rarityStats.map((v) => 255) + rarityStats.map(() => 255) ) * 100 ); statMeans[statsMean.toString()] = statMeans[statsMean.toString()] ? statMeans[statsMean.toString()] + 1 : 1; } }); - const standardStatMeans = {} as any; + const standardStatMeans = {} as Record; Object.entries(rarities.standard).forEach(([rarity]) => { for (let i = 0; i < loopCount; i++) { const dna = df.generateNeftyDNA(ef.hatch().archetypeKey, 'prime', undefined, rarity as Rarity); @@ -36,7 +36,7 @@ async function run(loopCount: number): Promise { const statsMean = Math.floor( utils.getAverageFromRaw( rarityStats.map((v) => parsed.raw[v]), - rarityStats.map((v) => 255) + rarityStats.map(() => 255) ) * 100 ); standardStatMeans[statsMean.toString()] = standardStatMeans[statsMean.toString()] diff --git a/ts/tsconfig.build.json b/ts/tsconfig.build.json index 9865daa..25d4201 100644 --- a/ts/tsconfig.build.json +++ b/ts/tsconfig.build.json @@ -3,5 +3,5 @@ "rootDir": "./src" }, "extends": "./tsconfig.json", - "exclude": ["node_modules", "**/tests/*"] + "exclude": ["node_modules", "**/tests/*", "scripts"] } diff --git a/ts/yarn.lock b/ts/yarn.lock index 58800f1..83384b0 100644 --- a/ts/yarn.lock +++ b/ts/yarn.lock @@ -1640,6 +1640,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"