(Re)Translating Nook Exchange
2021.11.24, last updated 2021.11.30 - Jan Reggie Dela Cruz - ~26 Minutes
In this post, I contribute to an open source project called Nook Exchange by introducing translations for several items in the website. I sourced these translations from an existing spreadsheet, and transformed the CSV data to JSON files for the project to use.
Background
Nook Exchange is an Animal Crossing: New Horizons catalog site, where users can list which in-game items they have and they’re willing to part. A lot of items have recently been introduced in the game in a recent update, and the website has yet to update their translations for other languages.
The Nook Exchange project contains files which can be modified to accomodate these translations, and a recent spreadsheet by AeonSake , which I will refer to as the “Aeon Spreadsheet”, contains the translations of these new items, directly sourcing them from the in-game files. However, the “format” for the Aeon Spreadsheet and that of the Nook Exchange files differ by a lot.
Objectives
I should be able to do the following:
- Read the appropriate files from Nook Exchange and parse them properly
- Read the Aeon Spreadsheet to extract data for all translations
- Update the translation files in Nook Exchange using the data from the Aeon Spreadsheet
Note that I said “extract data for all translations”. I will override some of the old translations in the Nook Exchange project, as the game update has also introduced new translations to some old items.
I will be using yarn and TypeScript for this project, because that just works. The source code can be found in janreggie/nook-exchange-translator .
Nook Exchange object structure
Before we can even get started changing the Nook Exchange files, we have to understand the files that we will be working on:
/src/items.json
: Items/src/variants.json
: Adjectives/src/translations/<loc>.json
: Translations
Items
Central to the project’s purpose,
Items represent the items found in the game.
They’re all represented as a list in a single /src/items.json
that looks like the following:
[
{
"id": 13018,
"name": "Animal nose",
"category": "accessories",
"variants": [],
"image": "AccessoryMouthHalloween0",
"flags": 2,
"source": "Able Sisters",
"buy": 560,
"sell": 140,
"tags": [ "1.5.0" ]
},
{
"id": 4463,
"name": "3D glasses",
"category": "accessories",
"variants": [ 2 ],
"image": "AccessoryGlassThreed",
"flags": 2,
"source": "Able Sisters",
"buy": 490,
"sell": 122,
"tags": [ "1.0.0" ]
},
{
"id": 12543,
"name": "Nintendo Switch",
"category": "miscellaneous",
"variants": [ 2, 1 ],
"image": "FtrSwitch_Remake",
"flags": 6,
"source": "Nintendo; Nook Shopping Daily Selection",
"buy": 29980,
"sell": 7495,
"kitCost": 7,
"tags": [ "1.1.0" ]
},
{
"id": 1779,
"name": "Table with cloth",
"category": "housewares",
"variants": [ 1, 8 ],
"image": "FtrTableCloth_Remake",
"flags": 10,
"source": "Nook's Cranny",
"buy": 5100,
"sell": 1275,
"kitCost": 2,
"tags": [ "1.0.0", "restaurant", "party", "table" ]
},
{
"id": 3449,
"name": "Wooden stool",
"category": "housewares",
"variants": [ 8, 5 ],
"image": "FtrWoodenStoolS_Remake",
"flags": 12,
"source": "Crafting",
"sell": 480,
"kitCost": 2,
"recipe": [ 168, "Peppy villagers", [ 4, "Wood" ] ],
"tags": [ "1.0.0", "wooden", "chair" ]
},
// ...
]
Most of these fields will be irrelevant to us. Let us focus on the following four:
id: number
lists the unique (for Nook Exchange) ID of the Item.name: string
lists the unique name of the Item in the American English locale, with some minor modifications that will be described much later.variants: [] | [number] | [number, number]
describes the number of Variants and Patterns of this item. More on this in the next section.recipe? : [number, string, ...Array<[number,string]>]
describes the Material Items that this Item may need. More on this much later.
The Item’s ID will be important in interacting with the other files in Nook Exchange, while Name will be important in interacting with the Aeon Spreadsheet.
Variants, Patterns, and Adjectives
An item in-game may have multiple different “variations”, represented as a change in the item’s appearance, color, design, etc. In the game’s internal files, these “variations” are in the form of “Variants” and “Patterns”: the former determining the item’s general appearance, and the latter determining a variation of a certain feature of the item.
Let us name all the Variants or Patterns of a certain Item its “Adjectives”.
Here is a sample of /src/variants.json
.
Note that key "13018"
(“Animal nose”) does not exist in the file,
because in the previous section we see it having variants: []
.
{
"4463": [ "White", "Black" ], // 3D glasses
"12543": [ // Nintendo Switch
[ "Neon blue & neon red", "Gray" ],
[ "NA" ]
],
"3449": [ // Wooden stool
[ "Light wood", "White wood", "Cherry wood", "Dark wood",
"Black", "Green", "Blue", "Pink wood" ],
[ "None", "Orange", "Pink", "Blue", "Green" ]
],
"1779": [ // Table with cloth
[ "" ],
[ "White", "Brown", "Green", "Red",
"Pink", "Yellow", "Light green", "Light blue" ]
],
// ...
}
The JSON file can be described as follows:
export type Adjectives = string[] | [string[], string[]]
type rawAdjectives = { [key: string]: Adjectives }
where key
is the Item’s ID.
The former string[]
is followed by a subcategory of Items
(more on this much later, don’t worry)
while the latter is followed by most other Items.
While "NA"
and ""
are Adjectives
that describe the “Nintendo Switch”’s only Pattern and the “Table with cloth”’s only Variant,
I will not be treating them as such,
because they will not appear in the Aeon Spreadsheet,
as we will see in a while.
Note: The “Wooden stool” has five variants listed in Nook Exchange while the screencap in the game shows six. The “sixth Pattern” means that the Item is “custom-design customizable”, something which will be irrelevant in our case but is described in the
flags
field of that Item.
Translations
Finally, the Translations list the translations of all the Items and Adjectives per locale.
They are stored in /src/translations/<loc>.json
,
where <loc>
is as follows:
de
: Germanen-gb
: British Englishes-eu
: European Spanishes-us
: (Latin) American Spanishfr-eu
: European Frenchfr-us
: American (Canadian) Frenchit
: Italianja
: Japaneseko
: Koreannl
: Dutchru
: Russianzh-cn
: Simplified Chinesezh-tw
: Traditional Chinese
Let us take a look at a sample of /src/translations/es-eu.json
for the Items that I have listed above:
{
"items": {
"13018": [ "Nariz de animal" ],
"4463": [
"Gafa 3D",
[ "Blanco", "Negro" ]
],
"12543": [
"Nintendo Switch",
[ [ "Rojo neón / azul néon", "Gris" ],
[ "" ] ]
],
"1779": [
"Mesa con mantel",
[ [ "" ],
[ "Blanco", "Marrón", "Verde", "Rojo",
"Rosa", "Amarillo", "Verde claro", "Celeste" ] ]
],
"3449": [
"Taburete de madera",
[
[ "Madera clara", "Blanco", "Cereza", "Madera oscura",
"Negro", "Verde", "Azul", "Rosa" ],
[ "Ninguna", "Naranja", "Rosa", "Azul", "Verde" ] ]
],
// ...
},
"materials": {
"Wood": "Tablas normales",
"Hardwood": "Tablas rígidas",
"Maple leaf": "Hoja de arce",
"Giant clam": "Almeja gigante",
// ...
}
}
For this file, we see a JSON Object with two keys:
items
: Translations for each Item and its Adjectives, mapped via IDsmaterials
: Translations for special “Material Items”
Observe as well that the Patterns for the “Nintendo Switch”
and the Variants for the “Wooden stool”
are translated as [""]
.
The JSON file can be described as follows:
interface rawTranslations {
items: { [key: string]: [ string, (string[] | [string[], string[]])? ] };
materials: { [key: string]: string };
}
Sidenote on materials
In the game, Materials are special Items
which the player can use to create some particular Item.
For example, by referring to its entry in items.json
,
we see that the Materials for the “Wooden stool”
is “Wood”, four of them.
Earlier, it is mentioned that an Item has an optional recipe
field,
that lists the “Materials” for a certain Item.
materials
lists all the translations for these Materials.
Parsing the source files
I have copied the relevant JSON files in /src/*.json
and /src/translations
to the /old-json
folder in the nook-exchange-translator
repository.
I used a tool called quicktype to easily parse the entire JSON file and create “converters” for each, which parse these JSON files into usable objects and vice versa. While the interfaces generated by quicktype work, I modified them slightly to suit my needs.
The following code simply reads items.json
and variants.json
and prints out a small sample of their entries:
import { Convert as ItemsConvert, Item as ItemType } from './json-parser/Items'
import { Convert as AdjectivesConvert, Adjectives as AdjectivesType } from './json-parser/Adjectives'
import { LowercaseName } from './util'
const OLD_JSON_DIR = './old-json'
async function main () {
// Read Nook Exchange data. Mapped using their Nook Exchange ID
const NookExchangeItems : Map<number, ItemType> = ItemsConvert.fileToItems(`${OLD_JSON_DIR}/items.json`)
const NookExchangeAdjectives : Map<number, AdjectivesType> = AdjectivesConvert.fileToAdjectives(`${OLD_JSON_DIR}/variants.json`)
const nameToNookExchangeId = new Map<string, number>()
for (const [k, v] of NookExchangeItems.entries()) {
if (nameToNookExchangeId.has(v.name.toLocaleLowerCase())) {
console.error(`Duplicate item ${v.name} in list of items`)
}
nameToNookExchangeId.set(LowercaseName(v.name), k)
}
const allMaterials = new Set<string>() // All material Items.
for (const item of NookExchangeItems.values()) {
if (!item.recipe) {
continue
}
const [, , ...requirements] = item.recipe
for (const item of requirements) {
allMaterials.add(item[1])
}
}
// Read some items to see if things go well
for (const id of [13018, 4463, 12543, 1779, 3449]) {
console.log(`For ID ${id}`)
console.log('Item: ', NookExchangeItems.get(id))
console.log('Adjectives: ', NookExchangeAdjectives.get(id))
console.log()
}
}
main()
Here, the “converters” ItemsConvert.fileToItems
and AdjectivesConvert.fileToAdjectives
read the respective JSON files
and create two Map
objects
whose keys are the Item IDs.
I have also created a nameToNookExchangeId
mapping the Nook Exchange Item names to their respective IDs,
as well as allMaterials : Set<string>
listing all mateirals,
which I will be using much later on
in recreating the Translations JSON files.
Running the above code yields the following output, which I have modified the slightly to reduce the line count:
$ yarn dev
For ID 13018
Item: {
id: 13018,
name: 'Animal nose',
category: 'accessories',
variants: [],
image: 'AccessoryMouthHalloween0',
flags: 2,
source: 'Able Sisters',
buy: 560,
sell: 140,
tags: [ '1.5.0' ],
recipe: undefined,
kitCost: undefined
}
Adjectives: undefined
For ID 4463
Item: {
id: 4463,
name: '3D glasses',
category: 'accessories',
variants: [ 2 ],
image: 'AccessoryGlassThreed',
flags: 2,
source: 'Able Sisters',
buy: 490,
sell: 122,
tags: [ '1.0.0' ],
recipe: undefined,
kitCost: undefined
}
Adjectives: [ 'White', 'Black' ]
For ID 12543
Item: {
id: 12543,
name: 'Nintendo Switch',
category: 'miscellaneous',
variants: [ 2, 1 ],
image: 'FtrSwitch_Remake',
flags: 6,
source: 'Nintendo; Nook Shopping Daily Selection',
buy: 29980,
sell: 7495,
tags: [ '1.1.0' ],
recipe: undefined,
kitCost: 7
}
Adjectives: [ [ 'Neon blue & neon red', 'Gray' ], [ 'NA' ] ]
For ID 1779
Item: {
id: 1779,
name: 'Table with cloth',
category: 'housewares',
variants: [ 1, 8 ],
image: 'FtrTableCloth_Remake',
flags: 10,
source: "Nook's Cranny",
buy: 5100,
sell: 1275,
tags: [ '1.0.0', 'restaurant', 'party', 'table' ],
recipe: undefined,
kitCost: 2
}
Adjectives: [
[ '' ],
[ 'White', 'Brown', 'Green', 'Red',
'Pink', 'Yellow', 'Light green', 'Light blue' ]
]
For ID 3449
Item: {
id: 3449,
name: 'Wooden stool',
category: 'housewares',
variants: [ 8, 5 ],
image: 'FtrWoodenStoolS_Remake',
flags: 12,
source: 'Crafting',
buy: undefined,
sell: 480,
tags: [ '1.0.0', 'wooden', 'chair' ],
recipe: [ 168, 'Peppy villagers', [ 4, 'Wood' ] ],
kitCost: 2
}
Adjectives: [
[ 'Light wood', 'White wood', 'Cherry wood', 'Dark wood',
'Black', 'Green', 'Blue', 'Pink wood' ],
[ 'None', 'Orange', 'Pink', 'Blue', 'Green' ]
]
Okay. Let’s move on.
Aeon Spreadsheet and CSV files’ structure
I downloaded the Google Sheet into the nook-exchange-translator
directory
and ran the following command to convert the spreadsheet
into a bunch of CSV files:
xlsx2csv -a "ACNH Translations [v2.0.0].xlsx" aeon-csvs
The structure of the files is very straightforward: the header of the Aeon CSVs lists the various locales, and each row contains the translations for each “entity” of the game, where an “entity” is a much more general representation of an in-game item. Entites include the Items in the Nook Exchange project, its Variants and Patterns, and other “objects” that the project doesn’t list.
Let us look at some rows for a few files:
Furniture.csv
, Item Variant Names.csv
, and Item Pattern Names.csv
:
Id,EUde,EUen,EUit,EUnl,EUru,EUfr,EUes,USen,USfr,USes,JPja,KRko,TWzh,CNzh
// Furniture.csv
Ftr_12543,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,консоль Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch,Nintendo Switch
Ftr_01779,Tischdeckentisch,table with cloth,tavolo con tovaglia,tafel met tafelkleed,стол со скатертью,table nappée,mesa con mantel,table with cloth,table nappée,mesa con mantel,クロスつきテーブル,사각 테이블,附桌巾桌子,附桌巾桌子
Ftr_03449,Holzschemel,wooden stool,sgabello di legno,houten kruk,деревянный табурет,tabouret en bois,taburete de madera,wooden stool,tabouret en bois,taburete de madera,もくせいスツール,목제 스툴,木製凳子,木制凳子
// Item Variant Names.csv
Ftr_12543_0,Neon-blau/neon-rot,Neon blue & neon red,Blu neon/rosso neon,Neonblauw-neonrood,Неон. синий и красный,Bleu néon et rouge néon,Rojo neón / azul néon,Neon blue & neon red,Bleu néon et rouge néon,Rojo neón y azul neón,ネオンブルー・ネオンレッド,네온 블루&네온 레드,電光藍‧電光紅,电光蓝·电光红
Ftr_12543_1,Grau,Gray,Grigio,Grijs,Серый,Gris,Gris,Gray,Gris,Gris,グレー,그레이,灰色,灰色
Ftr_03449_0,Helles Holz,Light wood,Legno chiaro,Blank,Светлое дерево,Bois clair,Madera clara,Light wood,Bois clair,Madera clara,ライトウッド,라이트 우드,淺木色,浅木色
Ftr_03449_1,Weißes Holz,White wood,Legno bianco,Wit,Белое дерево,Bois blanc,Blanco,White wood,Bois blanc,Blanco,ホワイトウッド,화이트 우드,白木色,白木色
Ftr_03449_2,Kirschholz,Cherry wood,Legno di ciliegio,Rood,Вишневое дерево,Merisier,Cereza,Cherry wood,Merisier,Cereza,チェリーウッド,체리 우드,櫻桃木色,樱桃木色
Ftr_03449_3,Dunkles Holz,Dark wood,Legno scuro,Bruin,Темное дерево,Bois sombre,Madera oscura,Dark wood,Bois sombre,Madera oscura,ダークウッド,다크 우드,深木色,深木色
Ftr_03449_4,Schwarz,Black,Nero,Zwart,Черный цвет,Noir,Negro,Black,Noir,Negro,ブラック,블랙,黑色,黑色
Ftr_03449_5,Grün,Green,Verde,Groen,Зеленый цвет,Vert,Verde,Green,Vert,Verde,グリーン,그린,綠色,绿色
Ftr_03449_6,Blau,Blue,Blu,Blauw,Синий цвет,Bleu,Azul,Blue,Bleu,Azul,ブルー,블루,藍色,蓝色
Ftr_03449_7,Rosa Holz,Pink wood,Legno rosa,Roze,Розовое дерево,Bois rose,Rosa,Pink wood,Bois rose,Madera rosada,ピンクウッド,핑크 우드,粉紅木色,粉红木色
// Item Pattern Names.csv
Ftr_01779_0,Weiß,White,Bianco,Wit,Белый,Blanc,Blanco,White,Blanc,Blanco,ホワイト,화이트,白色,白色
Ftr_01779_1,Braun,Brown,Marrone,Bruin,Коричневый,Brun,Marrón,Brown,Brun,Marrón,ブラウン,브라운,棕色,棕色
Ftr_01779_2,Grün,Green,Verde,Groen,Зеленый,Vert,Verde,Green,Vert,Verde,グリーン,그린,綠色,绿色
Ftr_01779_3,Rot,Red,Rosso,Rood,Красный,Rouge,Rojo,Red,Rouge,Rojo,レッド,레드,紅色,红色
Ftr_01779_4,Rosa,Pink,Rosa,Roze,Розовый,Rose,Rosa,Pink,Rose,Rosa,ピンク,핑크,粉紅色,粉红
Ftr_01779_5,Gelb,Yellow,Giallo,Geel,Желтый,Jaune,Amarillo,Yellow,Jaune,Amarillo,イエロー,옐로,黃色,黄色
Ftr_01779_6,Hellgrün,Light green,Verde chiaro,Lichtgroen,Светло-зеленый,Vert clair,Verde claro,Light green,Vert clair,Verde claro,ライトグリーン,라이트 그린,淺綠色,浅绿色
Ftr_01779_7,Hellblau,Light blue,Blu chiaro,Lichtblauw,Голубой,Bleu clair,Celeste,Light blue,Bleu pâle,Celeste,ライトブルー,라이트 블루,淺藍色,浅蓝色
Ftr_03449_0,Ohne,None,Nulla,Geen,Без подушки,Sans,Natural,None,Sans,Natural,なし,없음,無,无
Ftr_03449_1,Orange,Orange,Arancio,Oranje,Оранжевый,Orange,Naranja,Orange,Orange,Naranja,オレンジ,오렌지,橘色,橘色
Ftr_03449_2,Rosa,Pink,Rosa,Roze,Розовый,Rose,Rosa,Pink,Rose,Rosa,ピンク,핑크,粉紅色,粉红
Ftr_03449_3,Blau,Blue,Blu,Blauw,Синий,Bleu,Azul,Blue,Bleu,Azul,ブルー,블루,藍色,蓝色
Ftr_03449_4,Grün,Green,Verde,Groen,Зеленый,Vert,Verde,Green,Vert,Verde,グリーン,그린,綠色,绿色
Using the IDs for Item files such as Furniture.csv
(the “Aeon ID”),
one can look at the Variants and Patterns at Item Variant Names.csv
and Item Pattern Names.csv
.
As noticed, Item Variant Names.csv
doesn’t list the one (empty) Variant for the “Table with cloth”
and Item Pattern Names.csv
doesn’t list the one (empty) Pattern for the “Nintendo Switch”.
Parsing these files should be simple.
I created a custom type AeonTranslations
which contains the ID of that “entity” as well as its translations:
import { parseFile } from 'fast-csv'
export interface AeonTranslations {
Id: string;
EUde: string;
EUen: string;
EUit: string;
EUnl: string;
EUru: string;
EUfr: string;
EUes: string;
USen: string;
USfr: string;
USes: string;
JPja: string;
KRko: string;
TWzh: string;
CNzh: string;
}
// This will be important in writing the new JSON translations files
export function GetTranslation (translations : AeonTranslations, locale: string) : string {
switch (locale) {
case 'de': return translations.EUde
case 'en-gb': return translations.EUen
// Other cases go here...
default: return ''
}
}
export async function Parser (filename : string) : Promise<AeonTranslations[]> {
return new Promise((resolve, reject) => {
const result : AeonTranslations[] = []
parseFile(filename, { headers: true })
.on('error', (error : any) => reject(error))
.on('data', (row : AeonTranslations) => result.push(row))
.on('end', () => resolve(result))
})
}
Putting it in our /src/app.ts
:
import { AeonTranslations, GetTranslation, Parser as AeonParser } from './aeon-parser'
async function main() {
// ...
const allItemTranslations = new Map<string, AeonTranslations>() // Aeon ID -> AeonTranslations
const nameToAeonId = new Map<string, string>() // lowercaseName -> AeonTranslations
const insertAeonTranslation = (translation : AeonTranslations) => {
if (allItemTranslations.has(translation.Id)) {
throw new Error(`Item ${translation.USen} of ID ${translation.Id} already exists as ${allItemTranslations.get(translation.Id)!.USen}`)
}
allItemTranslations.set(translation.Id, translation)
const name = LowercaseName(translation.USen)
if (nameToAeonId.has(name)) {
throw new Error(`In inserting ${translation.Id}, item ${name} already exists as ID ${nameToAeonId.get(name)}`)
}
nameToAeonId.set(name, translation.Id)
}
const insertAeonTranslations = (translations : AeonTranslations[]) => { translations.forEach(item => insertAeonTranslation(item)) }
const nonSpecialCategories = [ 'Art', 'Bug Models', 'Bugs', 'Crafting Items', 'Dishes', 'Door Deco', 'Etc', 'Event Items', 'Fencing', 'Fish Models', 'Fish', 'Floors', 'Fossils', 'Furniture', 'Gyroids', 'Money', 'Music', 'Plants', 'Posters', 'Rugs', 'Sea Creatures', 'Shells', 'Tools', 'Turnips', 'Umbrellas', 'Wallpaper' ]
insertAeonTranslations(await OpenAeons(nonSpecialCategories))
// All Adjective Translations go here.
// Map AeonItemId -> Translation.USen -> Translations object
const allAdjectiveVariantTranslations = new Map<string, Map<string, AeonTranslations>>()
const allAdjectivePatternTranslations = new Map<string, Map<string, AeonTranslations>>()
for (const item of (await OpenAeon('Item Variant Names'))) {
const ids = item.Id.split('_')
if (ids.length !== 3) {
throw new Error(`invalid id ${item.Id} for item ${item.USen}`)
}
const itemId = ids[0] + '_' + ids[1]
let mp = allAdjectiveVariantTranslations.get(itemId)
if (mp === undefined) {
mp = new Map()
allAdjectiveVariantTranslations.set(itemId, mp)
}
mp.set(item.USen, item)
}
for (const item of (await OpenAeon('Item Pattern Names'))) {
const ids = item.Id.split('_')
if (ids.length !== 3) {
console.error(`invalid id ${item.Id} for item ${item.USen}`)
return
}
const itemId = ids[0] + '_' + ids[1]
let mp = allAdjectivePatternTranslations.get(itemId)
if (mp === undefined) {
mp = new Map()
allAdjectivePatternTranslations.set(itemId, mp)
}
mp.set(item.USen, item)
}
}
// Opens an Aeon CSV
async function OpenAeon (name : string) : Promise<Array<AeonTranslations>> {
return AeonParser(`${AEON_CSV_DIR}/${name}.csv`)
}
async function OpenAeons (names : Array<string>) : Promise<AeonTranslations[]> {
return (await Promise.all(names.map(name => OpenAeon(name)))).reduce((a, b) => a.concat(b))
}
What I have set-up here are the following:
allItemTranslations
contains all Translations for entities (except Adjectives) in the Aeon Spreadsheet. They are mapped using their Aeon IDs.nameToAeonId
maps the lowercase American English translation of that entity into that entity’s Aeon ID, just likenameToNookExchangeId
. These lowercase “names” are the bridge between the Aeon CSVs and the Nook Exchange JSONs, because the Nook Exchange files’ IDs are different to that of the Aeon entities'.- While the above examples does make it appear that they are the same, this isn’t the case for all items in both files. We will also soon observe that mapping lowercase names will be problematic.
allAdjectiveVariantTranslations
andallAdjectivePatternTranslations
are self explanatory, maps whose keys are Aeon IDs, and values are themselves maps, whose keys are the American English translation and values are the correspondingAeonTranslations
object.- An example:
allAdjectiveVariantTranslations['Ftr_03449']['Dark wood']
returns anAeonTranslations
object which contains the translations of the “Dark wood” variation for item with ID"Ftr_03449"
(“Wooden stool”). - Mapping the American English translations will be useful
because the Translations files that we will be creating
will be based from
/src/variants.json
, which store the Adjectives in American English.
- An example:
Special case: Clothing
Let us take a look at /aeon-csvs/Wetsuits.csv
:
Id,EUde,EUen,EUit,EUnl,EUru,EUfr,EUes,USen,USfr,USes,JPja,KRko,TWzh,CNzh
1258,Nook-Inc.-Taucheranzug,Nook Inc. wet suit,muta Nook Inc.,Nook Inc.-duikpak,гидрокостюм Nook Inc.,combi plongée Nook Inc.,traje de buceo de Nook Inc.,Nook Inc. wet suit,combi plongée Nook Inc.,traje de buceo de Nook Inc.,たぬきかいはつマリンスーツ,Nook Inc. 잠수복,Nook Inc.潛水衣,Nook Inc.潜水衣
1259,Blattmuster-Taucheranzug,leaf-print wet suit,muta con foglia,duikpak met bladerprint,гидрокостюм с листьями,combi plongée motif feuille,traje de buceo hoja,leaf-print wet suit,combi plongée motif feuille,traje de buceo hoja,はっぱがらマリンスーツ,나뭇잎 잠수복,葉子圖案潛水衣,叶子图案潜水衣
1260,Streifen-Taucheranzug,horizontal-striped wet suit,muta a righe orizzontali,gestreept duikpak,гидрокостюм в полоску,combi plongée à rayures,traje de buceo a rayas,horizontal-striped wet suit,combi plongée à rayures,traje de buceo a rayas,ボーダーのマリンスーツ,줄무늬 잠수복,粗橫紋潛水衣,粗横纹潜水衣
Let us then take a look at /Wetsuits Variants.csv
:
Id,EUde,EUen,EUit,EUnl,EUru,EUfr,EUes,USen,USfr,USes,JPja,KRko,TWzh,CNzh
1258_MarineSuit_12970,Keine Varianten,No Variations,Nessuna variazione,Geen variaties,Нет вариантов,Sans variation,Sin variaciones,No Variations,Sans variation,Sin variaciones,バリエーションなし,종류 없음,無其他種類,无其他种类
1259_MarineSuit_12981,Hellblau,Light blue,Blu chiaro,Lichtblauw,Голубой,Bleu clair,Celeste,Light blue,Bleu pâle,Celeste,ライトブルー,라이트 블루,淺藍色,浅蓝色
1259_MarineSuit_13098,Lila,Purple,Viola,Paars,Фиолетовый,Violet,Púrpura,Purple,Mauve,Morado,パープル,퍼플,紫色,紫色
1259_MarineSuit_13099,Gelb,Yellow,Giallo,Geel,Желтый,Jaune,Amarillo,Yellow,Jaune,Amarillo,イエロー,옐로,黃色,黄色
1259_MarineSuit_13100,Grün,Green,Verde,Groen,Зеленый,Vert,Verde,Green,Vert,Verde,グリーン,그린,綠色,绿色
1260_MarineSuit_12986,Rot,Red,Rosso,Rood,Красный,Rouge,Rojo,Red,Rouge,Rojo,レッド,레드,紅色,红色
1260_MarineSuit_13101,Blau,Blue,Blu,Blauw,Синий,Bleu,Azul,Blue,Bleu,Azul,ブルー,블루,藍色,蓝色
1260_MarineSuit_13102,Gelb,Yellow,Giallo,Geel,Желтый,Jaune,Amarillo,Yellow,Jaune,Amarillo,イエロー,옐로,黃色,黄色
1260_MarineSuit_13103,Schwarz,Black,Nero,Zwart,Черный,Noir,Negro,Black,Noir,Negro,ブラック,블랙,黑色,黑色
Inserting these “Clothing Items” should be straightforward, although with a few differences:
- Clothing Items only have Variants, not Patterns.
In
/src/items.json
, theirvariants
field is either an empty[]
or contain one value e.g.,[ 2 ]
. - The Variants of these Items can be found in multiple files just like the Items themselves,
unlike the others which are all in
Item Variant Names.csv
. - There is a slight difference in how the IDs for the Variants are structured. No worries about this.
Let us put them all in allItemTranslations
and allAdjectiveVariantTranslations
:
async function main() {
// ...
// Read data from Clothing
const clothingCategories = ['Accessories', 'Bags', 'Bottoms', 'Caps', 'Dress-Up', 'Handbags', 'Helmets', 'Shoes', 'Socks', 'Tops', 'Wetsuits' ]
insertAeonTranslations(await OpenAeons(clothingCategories))
// All Clothing Items have "variants", not "patterns".
const clothingAdjectives = await OpenAeons(clothingCategories.map(name => name + ' Variants'))
for (const item of clothingAdjectives) { // item.Id is in form ItemId_Category_VariantId
const ids = item.Id.split('_')
if (ids.length !== 3) {
throw new Error(`invalid id ${item.Id} for item ${item.USen}`)
}
const itemId = ids[0]
let mp = allAdjectiveVariantTranslations.get(itemId)
if (mp === undefined) {
mp = new Map()
allAdjectiveVariantTranslations.set(itemId, mp)
}
mp.set(item.USen, item)
}
}
Rectifying some items
Before I continue, running the above code will not work:
$ yarn dev
yarn run v1.22.17
$ ts-node ./src/app.ts
/home/janreggie/dev/nook-exchange-translator/src/app.ts:46
throw new Error(`In inserting ${translation.Id}, item ${name} already exists as ID ${nameToAeonId.get(name)}`)
^
Error: In inserting 077, item tank already exists as ID Ftr_13515
at insertAeonTranslation (/home/janreggie/dev/nook-exchange-translator/src/app.ts:46:13)
at /home/janreggie/dev/nook-exchange-translator/src/app.ts:50:104
at Array.forEach (<anonymous>)
at insertAeonTranslations (/home/janreggie/dev/nook-exchange-translator/src/app.ts:50:88)
at /home/janreggie/dev/nook-exchange-translator/src/app.ts:144:3
at Generator.next (<anonymous>)
at fulfilled (/home/janreggie/dev/nook-exchange-translator/src/app.ts:5:58)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
When the AeonTranslation
object of name “tank” and ID 077
(clothing) is inserted,
an error is thrown, saying that “tank” already exists as ID Ftr_13515
.
Let us look at these two items:
// Furniture.csv
Ftr_13515,Tank,tank,serbatoio,tank,резервуар,réservoir,cisterna,tank,réservoir,cisterna,タンク,탱크,燃料桶,燃料桶
// Tops.csv
077,Tanktop,tank,canotta,tanktop,топ,débardeur,top básico,tank,camisole,top básico,タンクトップ,탱크톱,坦克背心,坦克背心
As seen, there are two entities whose American English name is “tank”. This will be problematic, as we will be unsure which we should link to the two “tanks” in Nook Exchange.
Let’s look at how Nook Exchange deals with it in /src/items.json
:
[
{
"id": 3192,
"name": "Tank",
"category": "tops",
"variants": [
5
],
"image": "TopsTexTopTshirtsNTanktop",
"flags": 2,
"source": "Able Sisters",
"buy": 350,
"sell": 87,
"tags": [
"1.0.0"
]
},
{
"id": 13515,
"name": "Tank (houseware)",
"category": "housewares",
"variants": [
8,
7
],
"image": "FtrTankFactory_Remake",
"flags": 10,
"source": "HHP Office",
"buy": 44000,
"sell": 11000,
"kitCost": 7,
"tags": [
"2.0.0",
"lab",
"facility",
"ranch"
]
},
]
Note that all name
fields in items.json
are unique.
Because of this, the creator of the project had to create a distinction between the “tank” of water
and the “tank” top.
Solving this issue can be done by changing the column corresponding to USen
in Furniture.csv
:
// Old row
Ftr_13515,Tank,tank,serbatoio,tank,резервуар,réservoir,cisterna,tank,réservoir,cisterna,タンク,탱크,燃料桶,燃料桶
// New row
Ftr_13515,Tank,tank,serbatoio,tank,резервуар,réservoir,cisterna,tank (houseware),réservoir,cisterna,タンク,탱크,燃料桶,燃料桶
There will be other special files that we will need to rectify.
Special case: Art
In the game, and in the CSV file Art.csv
,
there exist “real” and “fake” art entities,
which are indistinguishible by name.
The Nook Exchange project distinguishes “Real Art” Items from “Fake Art” Items. For example, the Item with name “Scary painting” is distinct from “Scary painting (fake)”.
I have added " (fake)"
at the end of every USen
entry
and moved on.
Special case: Special items and “plural” entities
There are a lot more of these “duplicate” entities in files such as Etc.csv
and Crafting Items.csv
.
Consider the following lines in Crafting Items.csv
:
CraftMaterial_02767,Weichholz,softwood,legno morbido,zachthout,мягкая древесина,bois tendre,tabla flexible,softwood,bois tendre,tabla flexible,やわらかいもくざい,부드러운 목재,軟木材,软木材
CraftMaterial_02767_pl,Weichholz,softwood,legna morbida,zachthout,мягкая древесина,bois tendre,tablas flexibles,softwood,bois tendre,tablas flexibles,やわらかいもくざい,부드러운 목재,軟木材,软木材
The first row contains the translations of a single “softwood” item while the second row contains the translations of multiple “softwood” items (i.e., a stack of softwood). The translations may be different in Italian and Spanish, but they are the same for American English.
Looking at /old-json/translations/es-eu.json
in the materials
section,
“Softwood” is translated as “Tablas flexibles”.
I chose to eliminate the singular entries
because that’s what the project currently does.
Special case: Photos
Photos.csv
lists the translations of all “Photo Items” in the game.
They are all very similar to each other,
such that they all have the same Variants.
Looking at Item Variant Names.csv
,
only one Photo Item’s variants is listed:
that of Bromide_06426
(“Cyrano’s Photo”).
This is probably done to save space in the game developers’ part,
because, after all, all Photo Items’ variants are the same.
I “populated” the other Photo Items’ variants
using the data I have from Bromide_06426
:
async function main() {
// ...
const photoVariants = allAdjectiveVariantTranslations.get('Bromide_06426')!
for (const photoItem of await OpenAeon('Photos')) {
insertAeonTranslation(photoItem)
const localVariants = new Map()
for (const [k, v] of photoVariants) {
localVariants.set(k, { ...v, Id: photoItem.Id }) // Override Id for this particular photoItem
}
allAdjectiveVariantTranslations.set(photoItem.Id, localVariants)
}
}
Special case: Unreleased items
Consider the following line in Tops.csv
:
1234,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン,ぞうきんがけエプロン
This entity is untranslated! This is perhaps an “unreleased item” in the game whose data appears in the CSV files. I have eliminated them all, since they cause some problems especially for entities with duplicate names, probably because their current names are just placeholders for some future thing in the game.
Recreating the translation files
In this step,
the new translations JSON files
will be stored in ./new-json
,
to be used by Nook Exchange.
The actual code is a bit overwhelming, and can be found in janreggie/nook-exchange-translator , but I’ll be rewriting sections of it at a time here.
Before I continue with the code proper, here are some new constants we’ll be using:
|
|
Establishing a local translations type
|
|
Over here, we are iterating for every locale
,
and creating some hashmaps for our localTranslations
object
which will be written later on.
Additionally, we see a localize
function here.
It just takes in an AeonTranslations
object,
which we recall as a list of translations of some entity (Item or Adjective),
and returns the appropriate translation for this locale.
The CapitalizeName
function is here
because all translations in the Nook Exchange project
have their first character capitalized,
except for Russian (and CJK, obviously) names:
// util.ts
// capitalize the first letter of a name
export function CapitalizeName (name : string) : string {
if (!name) { return '' }
const first = name.charAt(0)
if (/[а-яА-ЯЁё]/.test(first)) { return name } // Skip Russian to follow Nook Exchange
return first.toUpperCase() + name.slice(1)
}
Going through every item and its translations
|
|
Recall that allItemTranslations
contains the Translations for all (non-Adjective) entities.
Some of these entities don’t belong at all in the Nook Exchange project,
so they’re printed out, and nothing happens.
Note the use of LowercaseName
because we have observed,
albeit for a brief while,
in §“Parsing the source files”
that the keys for nameToNookExchangeId
are the names in lowercase.
Looking for its and its Adjectives’ translations
|
|
localizedItem
is just the translation of that particular item.
Nothing too complicated there.
But localizedAdjectives
is a different story.
The switch
statement is there
to determine what kind of combination of Variants and Patterns does the item have,
or if it has none to begin with.
The key part here is the map
statement,
which takes in each element of originalAdjectives
(recall that NookExchangeAdjectives
is directly sourced from variants.json
)
and finds its equivalent in aeonVariants
and aeonPatterns
.
Note as well the original...length === 1
statement in case 2
.
Remember that some Items have “only one” Variant or Pattern in variants.json
,
(whether that be ['']
or ['NA']
)
which says that that Item’s Variants or Patterns in the Aeon CSVs don’t exist at all,
so running a aeonVariants.get
or aeonPatterns.get
will yield here undefined
.
With the inner for
loop closed,
let’s move on.
Going through every Material Item
|
|
Recall allMaterials
in §“Parsing the source files”.
This separate section of the JSON Translations
will have to be filled out.
This is also the reason
why I included some extra entities in allItemTranslations
.
For example, in Money.csv
,
only the entities “50,000 Bells” and “99,000 Bells” are used,
and the rest are just a waste.
Writing the files
|
|
Finally, it writes the files using fs.writeFile
.
Nothing too surprising here.
Curious error: Watering can’s variants
Running the code above without making any adjustments to variants.json
will yield an error.
Doing some debugging (by console.log
‘ing every nookExchangeItem
in the inner for
loop)
lets us know that the operations stop at some item of ID 2379
.
In variants.json
, we see that item:
{
// ...
"2379": [
[
"Blue",
"Orange",
"Green",
"Red",
"Purple",
"",
"White",
"Black"
],
[
"None"
]
],
// ...
}
Curiously, one of the Variants of Item 2379 (Watering Can) is an empty string.
Looking at the appropriate line in Item Variant Names.csv
,
I was able to deduce that the missing entry is “Pink”.
I filled this one up, and running the code now works.
Checking the new files
The new files in /new-json
will be different to /old-json/translations
,
obviously because of adding the translations for these new items,
but some Items and their Adjectives have indeed changed.
I copied the files in /new-json
over to /src/translations
,
overriding the existing files,
then minifying each.
I tested my local Nook Exchange instance,
and the new translations work!
Some diffs
In this section I’ll be using the European Spanish localization because that’s the non-English language I’m most familiar with.
The new update has updated some of the names of these items. For example, this “desert wallpaper” is now known as the “Moroccan wallpaper”:
There are several changes like that. And then, there is this curious diff that I stumbled upon:
Somehow they are different. Let us analyze both strings:
$ echo 'Camiseta n.º 1' | hexdump -C # Left
00000000 43 61 6d 69 73 65 74 61 20 6e 2e c2 ba 20 31 0a |Camiseta n... 1.|
00000010
$ echo 'Camiseta n.º 1' | hexdump -C # Right
00000000 43 61 6d 69 73 65 74 61 20 6e 2e c2 ba c2 a0 31 |Camiseta n.....1|
00000010 0a |.|
00000011
The difference between the two is that
the old translation separates the 'º'
and '1'
with a space (0x20
)
while the new one separates the two with a non-breaking space (0xC2A0
).
Wild.
Closing
This has been quite a project for me. It took me about two weeks to do all of this, going back and forth with the code to make sure it’s presentable for this blogpost, as well as a lot of debugging to make sure that the output format is “very close” to the Nook Exchange files.
I would like to thank Paul Shen (paulshen) for creating the Nook Exchange project, and for AeonSake for ripping the translations from the game. They have done great things for this game’s community.
This is one of those times when I’m glad that I’m a programmer. I was using an open-source service, got frustrated with a missing feature, and used code to implement that feature.
As of now, the change is still an open PR
.
I hope it will be merged in the near future.
Edit: the author created the translations himself
.
The pink watering can is still treated as an empty string, though.