diff --git a/src/main/java/dan200/computercraft/fabric/mixin/MixinLanguage.java b/src/main/java/dan200/computercraft/fabric/mixin/MixinLanguage.java index 60fdb1a89..677d8eb7a 100644 --- a/src/main/java/dan200/computercraft/fabric/mixin/MixinLanguage.java +++ b/src/main/java/dan200/computercraft/fabric/mixin/MixinLanguage.java @@ -3,73 +3,138 @@ * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ - package dan200.computercraft.fabric.mixin; +import dan200.computercraft.fabric.util.ServerTranslationEntry; +import net.minecraft.util.StringDecomposer; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.Style; +import net.minecraft.locale.Language; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; import com.google.common.collect.ImmutableMap; import com.google.gson.JsonParseException; -import dan200.computercraft.shared.peripheral.generic.data.ItemData; -import net.fabricmc.loader.api.FabricLoader; -import net.minecraft.locale.Language; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -import org.spongepowered.asm.mixin.injection.callback.LocalCapture; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.function.BiConsumer; +import java.util.stream.Collectors; /** - * Loads all mods en_us lang file into the default Language instance on the dedicated server. - * Needed so that lua code running on the server can access the display name of items. - * - * @see ItemData#fill + * Loads all mods' en_us lang file into the Language instance on the dedicated server with a basic strategy for + * resolving collisions. */ @Mixin( Language.class ) public class MixinLanguage { @Shadow + @Final private static Logger LOGGER; + @Shadow + @Final + private static String DEFAULT; @Shadow - public static void loadFromJson( InputStream inputStream, BiConsumer entryConsumer ) + private static void loadFromJson( InputStream inputStream, BiConsumer entryConsumer ) { } - private static void loadModLangFile( String modId, BiConsumer biConsumer ) + private static void loadModLangFile( ModContainer modContainer, BiConsumer biConsumer ) { - String path = "/assets/" + modId + "/lang/en_us.json"; + Path path = modContainer.getPath( "assets/" + modContainer.getMetadata().getId() + "/lang/" + DEFAULT + ".json" ); + if ( !Files.exists( path ) ) return; - try( InputStream inputStream = Language.class.getResourceAsStream( path ) ) + try ( InputStream inputStream = Files.newInputStream( path ) ) { - if( inputStream == null ) return; loadFromJson( inputStream, biConsumer ); } - catch( JsonParseException | IOException e ) + catch ( JsonParseException | IOException e ) { LOGGER.error( "Couldn't read strings from " + path, e ); } } - @Inject( method = "loadDefault", locals = LocalCapture.CAPTURE_FAILSOFT, at = @At( value = "INVOKE", remap = false, target = "Lcom/google/common/collect/ImmutableMap$Builder;build()Lcom/google/common/collect/ImmutableMap;" ) ) - private static void loadDefault( CallbackInfoReturnable cir, ImmutableMap.Builder builder ) + @Inject( method = "loadDefault", cancellable = true, at = @At( "HEAD" ) ) + private static void loadDefault( CallbackInfoReturnable cir ) { - /* We must ensure that the keys are de-duplicated because we can't catch the error that might otherwise - * occur when the injected function calls build() on the ImmutableMap builder. So we use our own hash map and - * exclude "minecraft", as the injected function has already loaded those keys at this point. - */ - HashMap translations = new HashMap<>(); + Map> translations = new HashMap<>(); - FabricLoader.getInstance().getAllMods().stream().map( modContainer -> modContainer.getMetadata().getId() ) - .filter( id -> !id.equals( "minecraft" ) ).forEach( id -> { - loadModLangFile( id, translations::put ); + for ( ModContainer mod : FabricLoader.getInstance().getAllMods() ) + { + loadModLangFile( mod, ( k, v ) -> { + if ( !translations.containsKey( k ) ) translations.put( k, new ArrayList<>() ); + translations.get( k ).add( new ServerTranslationEntry( mod.getMetadata(), k, v ) ); } ); + } - builder.putAll( translations ); + ImmutableMap.Builder builder = ImmutableMap.builder(); + + for ( Map.Entry> keyEntry : translations.entrySet() ) + { + if ( keyEntry.getValue().size() == 1 ) + { + // Only one value provided for this key + builder.put( keyEntry.getKey(), keyEntry.getValue().get( 0 ).value() ); + } + else + { + // Collision occurred for this key. + // Strategy: Resolve collision by choosing value provided by the mod that depends on the greatest number + // of other mods in this collision cluster, according to mod metadata. + // Rationale: The mod that intends to overwrite another mod's keys is more likely to declare the + // overwritee as a dependency. + Set clusterIds = keyEntry.getValue().stream().map( ServerTranslationEntry::getModId ).collect( Collectors.toSet() ); + ServerTranslationEntry pickedEntry = Collections.max( keyEntry.getValue(), + Comparator.comparingInt( entry -> entry.getDependencyIntersectionSize( clusterIds ) ) ); + builder.put( keyEntry.getKey(), pickedEntry.value() ); + } + } + + final Map map = builder.build(); + cir.setReturnValue( new Language() + { + @Override + public String getOrDefault( String key ) + { + return map.getOrDefault( key, key ); + } + + @Override + public boolean has( String key ) + { + return map.containsKey( key ); + } + + @Override + public boolean isDefaultRightToLeft() + { + return false; + } + + @Override + public FormattedCharSequence getVisualOrder( @NotNull FormattedText text ) + { + return visitor -> text.visit( ( style, string ) -> StringDecomposer.iterateFormatted( string, style, visitor ) ? Optional.empty() : FormattedText.STOP_ITERATION, Style.EMPTY ).isPresent(); + } + } ); } } diff --git a/src/main/java/dan200/computercraft/fabric/util/ServerTranslationEntry.java b/src/main/java/dan200/computercraft/fabric/util/ServerTranslationEntry.java new file mode 100644 index 000000000..74c17b526 --- /dev/null +++ b/src/main/java/dan200/computercraft/fabric/util/ServerTranslationEntry.java @@ -0,0 +1,36 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.fabric.util; + +import net.fabricmc.loader.api.metadata.ModDependency; +import net.fabricmc.loader.api.metadata.ModMetadata; + +import java.util.Set; +import java.util.stream.Collectors; + +// A utility class for holding translation mappings prior collision resolution. +public record ServerTranslationEntry(ModMetadata providingModMetadata, String key, String value ) +{ + public String getModId() + { + return providingModMetadata.getId(); + } + + public Set getDependencyIds() + { + Set deps = providingModMetadata.getDependencies().stream().map( ModDependency::getModId ) .collect( Collectors.toSet() ); + // For the purposes of handling key collisions, all mods should depend on minecraft + if ( !getModId().equals( "minecraft" ) ) deps.add( "minecraft" ); + return deps; + } + + public int getDependencyIntersectionSize( Set idSet ) + { + Set intersection = getDependencyIds(); + intersection.retainAll( idSet ); + return intersection.size(); + } +}