diff --git a/src/main/java/dan200/computercraft/fabric/mixin/MixinLanguage.java b/src/main/java/dan200/computercraft/fabric/mixin/MixinLanguage.java index fdd5eeeb3..f39a8218b 100644 --- a/src/main/java/dan200/computercraft/fabric/mixin/MixinLanguage.java +++ b/src/main/java/dan200/computercraft/fabric/mixin/MixinLanguage.java @@ -3,73 +3,131 @@ * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ - package dan200.computercraft.fabric.mixin; import com.google.common.collect.ImmutableMap; import com.google.gson.JsonParseException; -import dan200.computercraft.shared.peripheral.generic.data.ItemData; +import dan200.computercraft.fabric.util.ServerTranslationEntry; import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.client.font.TextVisitFactory; +import net.minecraft.text.OrderedText; +import net.minecraft.text.StringVisitable; +import net.minecraft.text.Style; import net.minecraft.util.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.util.HashMap; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; 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 load( 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; - load( inputStream, biConsumer ); + loadFromJson( inputStream, biConsumer ); } - catch ( JsonParseException | IOException e ) + catch( JsonParseException | IOException e ) { LOGGER.error( "Couldn't read strings from " + path, e ); } } - @Inject( method = "create", 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 create( CallbackInfoReturnable cir, ImmutableMap.Builder builder ) + @Inject( method = "create", cancellable = true, at = @At( "HEAD" ) ) + private static void create( 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 get( String key ) + { + return map.getOrDefault( key, key ); + } + + @Override + public boolean hasTranslation( String key ) + { + return map.containsKey( key ); + } + + @Override + public boolean isRightToLeft() + { + return false; + } + + @Override + public OrderedText reorder( @NotNull StringVisitable text ) + { + return visitor -> text.visit( ( style, string ) -> TextVisitFactory.visitFormatted( string, style, visitor ) ? Optional.empty() : StringVisitable.TERMINATE_VISIT, 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..410a486e7 --- /dev/null +++ b/src/main/java/dan200/computercraft/fabric/util/ServerTranslationEntry.java @@ -0,0 +1,57 @@ +/* + * 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 class ServerTranslationEntry +{ + private final ModMetadata providingModMetadata; + private final String key; + private final String value; + + public ServerTranslationEntry( ModMetadata providingModMetadata, String key, String value ) + { + this.providingModMetadata = providingModMetadata; + this.key = key; + this.value = value; + } + + public String key() + { + return key; + } + + public String value() + { + return value; + } + + public String getModId() + { + return providingModMetadata.getId(); + } + + public Set getDependencyIds() + { + Set deps = providingModMetadata.getDepends().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(); + } +}