Paradox.Modding.Core.psm1

function ConvertTo-ParadoxArray {
    <#
    .SYNOPSIS
        Converts an array into the paradox-config equivalent.
     
    .DESCRIPTION
        Converts an array into the paradox-config equivalent.
     
    .PARAMETER Data
        The entries in the array to convert
     
    .PARAMETER Indentation
        What indentation level to maintain within the rows.
     
    .EXAMPLE
        PS C:\> ConvertTo-ParadoxArray -Data $entries -Indentation 5
     
        Converts the provided array into the paradox-config equivalent with 5 tabs as indentation (6 for the individual entries).
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [object[]]
        $Data,

        [int]
        $Indentation
    )
    process {
        $results = do {
            '{'
            foreach ($line in $Data) { "$("`t" * ($Indentation + 1))$line"}
            "$("`t" * $Indentation)}"
        }
        while ($false)
        $results -join "`n"
    }
}

function ConvertTo-ParadoxConfig {
    <#
    .SYNOPSIS
        Builds a full Paradox Configuration file from hashtable.
     
    .DESCRIPTION
        Builds a full Paradox Configuration file from hashtable.
        The hashtable must have the same format as the file in the Paradox format is supposed to have.
 
        Tip: Use [ordered] hashtables in order to preserve order of entries.
     
    .PARAMETER Data
        The dat asets to convert into the target format.
     
    .PARAMETER Indentation
        What indentation level to start at.
        Will automatically increment for nested settings.
        Uses tabs for indentation.
     
    .PARAMETER TopLevel
        Whether the object provided is a top-levvel object.
        Top-Level Objects will not use opening and closing curly braces.
        They also skip the auto-indentation for direct members.
     
    .EXAMPLE
        PS C:\> $decisions | ConvertTo-ParadoxConfig
         
        Converts the decisions provided into valid mod strings.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [ValidateScript({ $_ -as [hashtable]})]
        [object[]]
        $Data,

        [int]
        $Indentation,

        [switch]
        $TopLevel
    )
    begin {
        $boolMap = @{
            $true = 'yes'
            $false = 'no'
        }

        $effectiveIndentation = $Indentation
        if (-not $TopLevel) { $effectiveIndentation++ }
    }
    process {
        foreach ($entry in $Data) {
            $results = do {
                if (-not $TopLevel) { "{" }

                foreach ($pair in $entry.GetEnumerator()) {
                    if ($null -eq $pair.Value) { throw "Error processing $($pair.Key): NULL is not a supported value!" }
                    $value = switch ($pair.Value.GetType()) {
                        ([string]) { $pair.Value }
                        ([int]) { $pair.Value }
                        ([double]) { $pair.Value }
                        ([bool]) { $boolMap[$pair.Value] }
                        ([hashtable]) { ConvertTo-ParadoxConfig -Data $pair.Value -Indentation $effectiveIndentation; break }
                        ([ordered]) { ConvertTo-ParadoxConfig -Data $pair.Value -Indentation $effectiveIndentation; break }
                        ([object[]]) { ConvertTo-ParadoxArray -Data $pair.Value -Indentation $effectiveIndentation }
                        default {
                            throw "Error processing $($pair.Key): Unexpected type $($pair.Value.GetType().FullName) | $($pair.Value)"
                        }
                    }
                    # Paradox Config can use the same key multiple times, hashtables cannot.
                    # To solve this, we can append a "þ<number>þ" suffix in the hashtable which is removed during conversion
                    "$("`t" * $effectiveIndentation)$($pair.Key -replace 'þ\d+þ') = $value"
                }

                if (-not $TopLevel) { "$("`t" * $Indentation)}" }
            }
            while ($false)
            $results -join "`n"
        }
    }
}

function Build-PdxMod {
    <#
    .SYNOPSIS
        Builds a a paradox game mod.
     
    .DESCRIPTION
        Builds a a paradox game mod.
        It will pick up all folders in the target Path with a matching name and treat them as mods.
        Then it builds & deploys them.
 
        This includes:
 
        - Copying the entire folder to a staging path.
        - Executing the build.ps1 script in the root folder (if present)
        - Apply any build extensions
        - Removing all PowerShell files from the mod structure
        - Replacing the mod with any previous versions in the destination path.
     
    .PARAMETER Path
        Directory where mod sources are looked for.
        Defaults to the current folder.
     
    .PARAMETER Name
        Name of the mod to build.
        Defaults to *
 
    .PARAMETER Tags
        Build extensions to apply based on their tags.
        These are provided by game-specific modules.
        This allows you to automatically apply game-specific build actions, without needing your own build script.
     
    .PARAMETER Game
        The game to build for.
        This is used to automatically pick the output folder for where that game is looking for mods.
     
    .PARAMETER OutPath
        The destination folder where to place the built mod.
        Use this if you do not want to deploy the mods straight to your game.
 
    .PARAMETER BadExtensions
        File extensions that should not remain in your mod structure.
        Before finalizing and deploying the mod, all files with one of the included extensions will be deleted from the staging copy of your mods.
        Defaults to: .ps1, .psd1
 
        Note: This will NOT affect the files in your source structure, only in the copy used to wrap up and deploy your mod.
 
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Build-PdxMod -Path C:\code\mods\Stellaris -Game Stellaris
 
        Picks up all folders under "C:\code\mods\Stellaris" and deploys them as Stellaris mod to where your Stellaris will look for mods.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Game')]
    param (
        [string]
        $Path = '.',

        [PsfArgumentCompleter('Paradox.Modding.ChildFolder')]
        [string]
        $Name = '*',

        [PsfArgumentCompleter('Paradox.Modding.BuildExtension.Tags')]
        [string[]]
        $Tags,

        [Parameter(Mandatory = $true, ParameterSetName = 'Game')]
        [ValidateSet('EU4', 'HOI4', 'Imperator', 'CK2', 'CK3', 'Victoria3', 'Stellaris')]
        [string]
        $Game,

        [Parameter(Mandatory = $true, ParameterSetName = 'Path')]
        [string]
        $OutPath,

        [string[]]
        $BadExtensions = @('.ps1','.psd1'),

        [switch]
        $EnableException
    )

    begin {
        #region Utility Functions
        function Build-ModInstance {
            [CmdletBinding()]
            param (
                [string]
                $Path,

                [string]
                $Name,

                [AllowEmptyCollection()]
                [string[]]
                $Tags,

                [AllowEmptyCollection()]
                [string[]]
                $BadExtensions
            )

            # Prepare Configuration
            $rootPath = Join-Path -Path $Path -ChildPath $Name
            $modCfg = @{
                Author  = 'unspecified'
                Version = 'unspecified'
            }
            $configPath = Join-Path -Path $rootPath -ChildPath 'config.psd1'
            if (Test-Path -Path $configPath) {
                $config = Import-PSFPowerShellDataFile -Path $configPath
                foreach ($entry in $config.GetEnumerator()) {
                    $modCfg[$entry.Key] = $entry.Value
                }
            }
            $modCfg.Name = $Name
            $modCfg.Root = $rootPath
            
            # Execute build script if present
            $buildPath = Join-Path -Path $rootPath -ChildPath 'build.ps1'
            if (Test-Path -Path $buildPath) {
                & $buildPath
            }

            # Apply Build Extensions
            if ($Tags) {
                $extensions = Get-PdxBuildExtension -Tags $Tags
                foreach ($extension in $extensions) {
                    Write-PSFMessage -Level Verbose -Message ' Applying build Extension: {0}' -StringValues $extension.Name
                    & $extension.Code $modCfg
                }
            }

            # Remove all PowerShell-native content from staging folder
            Get-ChildItem -Path $rootPath -Recurse | Where-Object Extension -In $BadExtensions | Remove-Item
        }

        function Deploy-ModInstance {
            [CmdletBinding()]
            param (
                [string]
                $Path,

                [string]
                $Name,

                [string]
                $Destination
            )

            $sourceRoot = Join-Path -Path $Path -ChildPath $Name
            $destinationRoot = Join-Path -Path $Destination -ChildPath $Name

            if (Test-Path -Path $destinationRoot) {
                Remove-Item -Path $destinationRoot -Recurse -Force
            }
            Move-Item -Path $sourceRoot -Destination $Destination -Force
        }
        #endregion Utility Functions

        if ($Game) {
            switch ($Game) {
                'CK3' { $OutPath = "$([System.Environment]::GetFolderPath("MyDocuments"))\Paradox Interactive\Crusader Kings III\mod" }
                'EU4' { $OutPath = "$([System.Environment]::GetFolderPath("MyDocuments"))\Paradox Interactive\Europa Universalis IV\mod" }
                'HOI4' { $OutPath = "$([System.Environment]::GetFolderPath("MyDocuments"))\Paradox Interactive\Hearts of Iron IV\mod" }
                'Imperator' { $OutPath = "$([System.Environment]::GetFolderPath("MyDocuments"))\Paradox Interactive\Imperator\mod" }
                'Stellaris' { $OutPath = "$([System.Environment]::GetFolderPath("MyDocuments"))\Paradox Interactive\Stellaris\mod" }
                default { Stop-PSFFunction -Message "Game $Game not implemented yet! Use '-OutPath' instead to manually pick the deployment path!" -Cmdlet $PSCmdlet -EnableException $true }
            }
        }

        $tempDirectory = New-PSFTempDirectory -ModuleName PDX -Name Staging
    }
    process {
        Write-PSFMessage -Level Host -Message "Building Mods in '$Path' to '$OutPath'"
        foreach ($modRoot in Get-ChildItem -Path $Path -Directory) {
            if ($modRoot.Name -notlike $Name) { continue }
            if ($modRoot.Name -eq '.vscode') { continue }

            try {
                Write-PSFMessage -Level Host -Message " Processing: {0}" -StringValues $modRoot.Name -Target $modRoot.Name
                Write-PSFMessage -Level Host -Message " Staging Mod" -Target $modRoot.Name
                Copy-Item -LiteralPath $modRoot.FullName -Destination $tempDirectory -Recurse
                
                Write-PSFMessage -Level Host -Message " Building Mod. Tags: {0}" -StringValues ($Tags -join ',') -Target $modRoot.Name
                Build-ModInstance -Path $tempDirectory -Name $modRoot.Name -Tags $Tags -BadExtensions $BadExtensions
                
                Write-PSFMessage -Level Host -Message " Deploying Mod" -Target $modRoot.Name
                Deploy-ModInstance -Path $tempDirectory -Name $modRoot.Name -Destination $OutPath
            }
            catch {
                Stop-PSFFunction -Message "Failed to build $($modRoot.Name)" -ErrorRecord $_ -EnableException $EnableException -Continue -Cmdlet $PSCmdlet -Target $modRoot.Name
            }
        }
    }
    end {
        Get-PSFTempItem -ModuleName PDX | Remove-PSFTempItem
    }
}

function ConvertTo-PdxConfigFormat {
    <#
    .SYNOPSIS
        Builds a full Paradox Configuration file text from hashtable.
     
    .DESCRIPTION
        Builds a full Paradox Configuration file text from hashtable.
        The hashtable must have the same format as the file in the Paradox format is supposed to have.
 
        Tip: Use [ordered] hashtables in order to preserve order of entries.
 
        Duplicate Keys:
        In the Paradox format, there are often multiple entries with the same name.
        E.g.: Decisions with three options: Each option starts on the saem "option" key.
        Hashtables in c#/PowerShell/.NET do not support duplicate keys!!
        In those situations, you can append an index behind the key, that will automatically be removed during conversion:
        "optionþ1þ" becomes "option"
        "optionþ2þ" becomes "option"
        "optionþ10þ" becomes "option"
 
        The "þ" character is the "Thorn" symbol from the icelandic language and should never conflict with an actual key value.
        It can be typed by keeping the left ALT key pressed and type "0254" on the numpad.
     
    .PARAMETER Data
        The data assets to convert into the target format.
     
    .PARAMETER Indentation
        What indentation level to start at.
        Will automatically increment for nested settings.
        Uses tabs for indentation.
     
    .PARAMETER TopLevel
        Whether the object provided is a top-levvel object.
        Top-Level Objects will not use opening and closing curly braces.
        They also skip the auto-indentation for direct members.
     
    .EXAMPLE
        PS C:\> $decisions | ConvertTo-PdxConfigFormat
         
        Converts the decisions provided into valid mod strings.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [ValidateScript({ $_ -as [hashtable]})]
        [object[]]
        $Data,

        [int]
        $Indentation,

        [switch]
        $TopLevel
    )
    begin {
        $boolMap = @{
            $true = 'yes'
            $false = 'no'
        }

        $effectiveIndentation = $Indentation
        if (-not $TopLevel) { $effectiveIndentation++ }
    }
    process {
        foreach ($entry in $Data) {
            $results = do {
                if (-not $TopLevel) { "{" }

                foreach ($pair in $entry.GetEnumerator()) {
                    if ($null -eq $pair.Value) { throw "Error processing $($pair.Key): NULL is not a supported value!" }
                    $value = switch ($pair.Value.GetType()) {
                        ([string]) { $pair.Value }
                        ([int]) { $pair.Value }
                        ([double]) { $pair.Value }
                        ([bool]) { $boolMap[$pair.Value] }
                        ([hashtable]) { ConvertTo-ParadoxConfig -Data $pair.Value -Indentation $effectiveIndentation; break }
                        ([ordered]) { ConvertTo-ParadoxConfig -Data $pair.Value -Indentation $effectiveIndentation; break }
                        ([object[]]) { ConvertTo-ParadoxArray -Data $pair.Value -Indentation $effectiveIndentation }
                        default {
                            Stop-PSFFunction -Message "Error processing $($pair.Key): Unexpected type $($pair.Value.GetType().FullName) | $($pair.Value)" -EnableException $true -Cmdlet $PSCmdlet
                        }
                    }
                    # Paradox Config can use the same key multiple times, hashtables cannot.
                    # To solve this, we can append a "þ<number>þ" suffix in the hashtable which is removed during conversion
                    "$("`t" * $effectiveIndentation)$($pair.Key -replace 'þ\d+þ') = $value"
                }

                if (-not $TopLevel) { "$("`t" * $Indentation)}" }
            }
            while ($false)
            $results -join "`n"
        }
    }
}

function New-PdxConfigEntry {
    <#
    .SYNOPSIS
        Create a configuration entry, later to be written to disk as a Paradox mod file.
     
    .DESCRIPTION
        Create a configuration entry, later to be written to disk as a Paradox mod file.
        This command should rarely be used directly and is meant for the other modules in the kit as a helper utility.
 
        It is part of the process converting a psd1-based configuration file into actual mod files.
        The results of this command are later processed by ...
        - "Export-PdxLocalizedString" for localization data
        - "ConvertTo-PdxConfigFormat" for export as a Paradox-compliant mod file
 
        Parameter Note: IDictionary
        Most parameters accept a System.Collections.IDictionary as input.
        In the context of PowerShell, this usually means one of two options:
        - A Hashtable: $output = @{}
        - An Ordered Dictionary: $output = [ordered]@{}
        The latter has the advantage of maintaining the correct order of properties and is recommended for modding with this tool.
 
        For an example implementation / usage, see here:
        https://github.com/FriedrichWeinmann/Paradox.Modding.Stellaris/blob/master/Paradox.Modding.Stellaris/functions/ConvertTo-PdsBuilding.ps1
     
    .PARAMETER Entry
        The individual configuration entry with the properties that make up the actual dataset.
        Whether that is a building definition, a new army type, and edict or whatever else.
     
    .PARAMETER Name
        The name of the entry.
        Used for localization keys.
     
    .PARAMETER Defaults
        Default settings that apply, if there is nothing specified in the Entry for it.
     
    .PARAMETER Output
        The result object.
        All processing is applied to this item.
        Specifying it allows you to pre-process it, adding the first entries yourself, before passing it to this command, which will affect the order of entries on the exported result.
        Defaults to: An empty ordered dictionary.
     
    .PARAMETER Common
        Common properties and their aliases.
        Entries specified here will be first - and in the order of its entries - in the exported mod data.
        It allows you to ensure a fairly consistent order for commonly needed properties.
        It also allows you to add aliases to some of the more common properties.
        Example:
        @{
            CanBuild = 'can_build'
            Potential = 'potential'
        }
        The first entry allows you to either specify "CanBuild" or "can_build" in the config file - they'll mean the same thing.
        The second entry ensures the casing of "Potential" in the exported mod file will always be "potential", no matter how it is specified in the config file.
        It will also ensure, that in all exported entries, "can_build" comes before "potential" and all other entries will be below them.
     
    .PARAMETER Strings
        The localization data that will later be exported.
        Use "New-PdxLocalizedString" to generate this item.
        This allows you to collect localized strings from multiple entries and only generate one localization file at the end of the process.
        For example, this allows your config file with ten buildings defined to only generate one set of localization files, containing all the strings of the ten buildings bundled together.
     
    .PARAMETER LocalizationProperties
        Which properties on the Entry correspond to localization entries and not mod dataset information.
        They will NOT become part of the mod data, and instead become part of the localization.
        Example:
        @{
            Name = 'edict_{0}'
            Description = 'edict_{0}_desc'
        }
        This would mean that for each entry, the configuration entries "Name" and "Description" will not become part of the mod, and instead become localization.
        If now our entire configuration entry be this:
        infernal_diplomacy = @{
            Name = "Infernal Diplomacy"
            Description = "Who wouldn't want to be your friend?"
            Modifier = @{
                envoys_add = 1
            }
        }
        Then this tool will generate two strings:
        infernal_diplomacy:0 "Infernal Diplomacy"
        infernal_diplomacy_desc:0 "Who wouldn't want to be your friend?"
     
    .PARAMETER TypeMap
        Given the way PowerShell works, sometimes types are not exactly straightforward.
        With this parameter we can define a specific data type for a setting on an entry.
        Example:
        @{
            upgrades = 'array'
            prerequisites = 'array'
        }
        If our configuration entry now specifies this:
        prerequisites = 'tech_basic_science_lab_2'
        Which is a plain string, it will then be converted into an array (with a single string) instead:
        prerequisites = @('tech_basic_science_lab_2')
     
    .PARAMETER Ignore
        Properties on Entry that should be ignored.
        Use this for properties you process outside of this command.
        For example, to simplify resource cost processing, which can be simple or incredibly complex, depending on how many conditions you want to apply or what kinds of resources you want to demand.
        Using Stellaris buildings for an example:
        - Most buildings cost Minerals to build (and nothing else).
        - Some buildings require rare resources as well.
        - Some buildings require rare resources, UNLESS a specific requirement is met.
         
        To make this convenient to mod, we want then support different specifications:
        - Provide a simple number, and we assume you mean minerals
        - Provide a simple hashtable, and we assume you provide your own calculation
 
        However, this command is not equipped to deal with this!
        So, in order to resolve this, you do that determination yourself and have this command skip the property instead.
     
    .EXAMPLE
        PS C:\> New-PdxConfigEntry -Entry $buildingData -Name $buildingName -Defaults $data.Core -Common $commonProps -Output $newBuilding -Strings $strings -LocalizationProperties $localeMap -Ignore Cost, Upkeep -TypeMap $typeMap
 
        Does wild stuff.
        Essentially, this merges the config entry's settings with the default settings, ensures strings are properly added to the list of strings to export, types are properly enforced and Cost and Upkeep properties are skipped.
     
    .LINK
        https://github.com/FriedrichWeinmann/Paradox.Modding.Stellaris/blob/master/Paradox.Modding.Stellaris/functions/ConvertTo-PdsBuilding.ps1
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [OutputType([System.Collections.IDictionary])]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]
        $Entry,

        [Parameter(Mandatory = $true, ParameterSetName = 'Localized')]
        [string]
        $Name,

        [System.Collections.IDictionary]
        $Defaults = @{},

        [System.Collections.IDictionary]
        $Output = ([ordered]@{ }),

        [System.Collections.IDictionary]
        $Common = @{ },

        [Parameter(Mandatory = $true, ParameterSetName = 'Localized')]
        [hashtable]
        $Strings,

        [Parameter(Mandatory = $true, ParameterSetName = 'Localized')]
        [hashtable]
        $LocalizationProperties,

        [hashtable]
        $TypeMap = @{},

        [string[]]
        $Ignore
    )
    begin {
        $nonCommonDefaults = @{ }
        foreach ($pair in $Defaults.GetEnumerator()) {
            if ($pair.Key -notin $Common.Keys) {
                $nonCommonDefaults[$pair.Key] = $pair.Value
            }
        }

        $propsToIgnore = @($Ignore) + $LocalizationProperties.Keys | Remove-PSFNull
    }
    process {
        #region Localization
        if ($Name) {
            foreach ($localizationEntry in $LocalizationProperties.GetEnumerator()) {
                if ($Entry.$($localizationEntry.Key) -is [string]) { Add-PdxLocalizedString -Key ($localizationEntry.Value -f $Name) -Text $Entry.$($localizationEntry.Key) -Strings $Strings }
                elseif ($Entry.$($localizationEntry.Key).english) { Add-PdxLocalizedString -Key ($localizationEntry.Value -f $Name) -Text $Entry.$($localizationEntry.Key).english -Localized $Entry.$($localizationEntry.Key) -Strings $Strings }
            }
        }
        #endregion Localization

        #region Well-Known Properties
        foreach ($property in $Common.GetEnumerator()) {
            if ($Entry.Keys -contains $property.Key) { $Output[$property.Value] = $Entry[$property.Key] }
            elseif ($Entry.Keys -contains $property.Value) { $Output[$property.Value] = $Entry[$property.Value] }
            elseif ($Defaults.Keys -contains $property.Key) { $Output[$property.Value] = $Defaults[$property.Key] }
            elseif ($Defaults.Keys -contains $property.Value) { $Output[$property.Value] = $Defaults[$property.Value] }
        }
        #endregion Well-Known Properties

        #region Other Nodes
        foreach ($pair in $Entry.GetEnumerator()) {
            if ($pair.Key -in $propsToIgnore) { continue } # Should Ignore
            if ($pair.Key -in $Common.Keys) { continue } # Handled in the last step
            $Output[$pair.Key] = $pair.Value
        }

        foreach ($pair in $nonCommonDefaults.GetEnumerator()) {
            if ($pair.Key -in $propsToIgnore) { continue }
            if ($pair.Key -in $Entry.Keys) { continue }
            $Output[$pair.Key] = $pair.Value
        }
        #endregion Other Nodes

        #region Process Type Conversion/Assurance
        foreach ($typeEntry in $TypeMap.GetEnumerator()) {
            if ($Output.Keys -notcontains $typeEntry.Key) { continue }

            switch ($typeEntry.Value) {
                'array' {
                    $Output[$typeEntry.Key] = @($Output[$typeEntry.Key])
                }
                default { Write-PSFMessage -Level Warning -Message 'Unexpected type coercion type for property {0}. Defined type "{1}" is not implemented' -StringValues $typeEntry.Key, $typeEntry.Value -Target $Output }
            }
        }
        #endregion Process Type Conversion/Assurance

        $Output
    }
}

function Get-PdxBuildExtension {
    <#
    .SYNOPSIS
        Lists registered paradox modding build extensions.
     
    .DESCRIPTION
        Lists registered paradox modding build extensions.
        These are used to help simplify mod build scripts and usually provided by game-specific modules.
     
    .PARAMETER Name
        Name of the extension to look for.
        Defaults to *
     
    .PARAMETER Tags
        Tags to search for.
        Any extension with at least one match is returned.
     
    .EXAMPLE
        PS C:\> Get-PdxBuildExtension
 
        Lists all registered paradox modding build extensions.
    #>

    [CmdletBinding()]
    param (
        [PsfArgumentCompleter('Paradox.Modding.BuildExtension.Name')]
        [string]
        $Name = '*',

        [PsfArgumentCompleter('Paradox.Modding.BuildExtension.Tags')]
        [string[]]
        $Tags
    )
    process {
        $script:buildExtensions.Values | Where-Object {
            $_.Name -like $Name -and
            (
                -not $Tags -or
                (@($_.Tags).Where{ $_ -in $Tags })
            )
        }
    }
}

function Register-PdxBuildExtension {
    <#
    .SYNOPSIS
        Register a new build logic specific to a given paradox game.
     
    .DESCRIPTION
        Register a new build logic specific to a given paradox game.
        These can be applied / selected when building mods to simplify/replace build scripts in mods.
     
    .PARAMETER Name
        Name of the build extension.
     
    .PARAMETER Description
        Description of what the build extension does.
     
    .PARAMETER Tags
        Tags to apply to the extension.
        Extensions are usually selected by tag.
        Each extension should at least include a tag for the game it applies to.
     
    .PARAMETER Code
        The code implementing the build logic.
        It will receive a single value as input: A hashtable with multiple entrie.
        As a least minimum, this will include:
        - Root: The Path to the mod being built
        - Name: The name of the mod
        - Author: The author of the mod
        - Version: The version of the mod
        Other than "Root" or "Name", data is taken from a "config.psd" file in the root mod folder if present.
        Individual games may define (and use) additional config settings as desired.
     
    .EXAMPLE
        PS C:\> Register-PdxBuildExtension -Name 'Stellaris.Edict' -Description 'Builds Stellaris edicts written as psd1 files' -Tags stellaris, edicts -Code $edictExt
 
        Registers a new build extension to simplify building Stellaris edicts.
    #>

    [CmdletBinding()]
    param (
        [PsfValidateScript('PSFramework.Validate.SafeName')]
        [string]
        $Name,

        [string]
        $Description,

        [string[]]
        $Tags,

        [scriptblock]
        $Code
    )
    process {
        $script:buildExtensions[$Name] = [PSCustomObject]@{
            PSTypeName  = 'Paradox.Modding.Core.BuildExtension'
            Name        = $Name
            Description = $Description
            Tags        = $Tags
            Code        = $Code
        }
    }
}

function Read-PdxFileSection {
    <#
    .SYNOPSIS
        Read a paradox game file and break it into individual entries.
     
    .DESCRIPTION
        Read a paradox game file and break it into individual entries.
        Use this to parse out individual entries in a file containing many elements.
        E.g.: Use this to get a list of all techs from all the files in the "common/technologies" folder (including the individual definitions).
     
    .PARAMETER Path
        Path to the files to parse.
 
    .PARAMETER IncludeComments
        Also include comments _within_ an individual entry.
        by default, commented out lines are not included.
        Comments at the root level will always be ignored.
     
    .EXAMPLE
        PS C:\> Get-ChildItem -Path .\common\technologies | Read-PdxFileSection
         
        Read all technologies from all tech files.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName')]
        [PSFFileLax]
        $Path,

        [switch]
        $IncludeComments
    )

    process {
        foreach ($badFile in $Path.FailedInput) {
            Write-PSFMessage -Level Warning -Message "Bad input - either does not exist or not a file: {0}" -StringValues $badFile
        }

        foreach ($file in $Path) {
            $lines = Get-Content -LiteralPath $file | Where-Object {
                $IncludeComments -or
                $_ -notmatch '^\s{0,}#'
            }

            $currentLines = @()
            foreach ($line in $lines) {
                # Skip empty lines outside of a section
                if (-not $line.Trim() -and -not $currentLines) { continue }

                # Skip leading declarations
                if (-not $currentLines -and $line -notmatch '=\s{0,}\{' -and $line -notmatch '^#') { continue }

                $currentLines += $line

                # Move to next line if not last line
                if ($line -notmatch '^}') { continue }

                [PSCustomObject]@{
                    File         = $file
                    Name         = $currentLines[0] -replace '.{0,}?(\w+)\s{0,}=.{0,}', '$1'
                    Lines        = $currentLines
                    LinesCompact = $currentLines | Get-SubString -Trim "`t "
                    Text         = $currentLines -join "`n"
                }
                $currentLines = @()
            }
        }
    }
}

function Update-PdxFileSection {
    <#
    .SYNOPSIS
        Modifies the content of a section read from a Paradox content file.
     
    .DESCRIPTION
        Modifies the content of a section read from a Paradox content file.
        Use this to modify the sections read through "Read-PdxFileSection".
 
        IMPORTANT:
        This does _NOT_ modify the files those were read from - you'll have to write back the sections yourself (or put them in new files).
     
    .PARAMETER Section
        The section data to modify.
        Use "Read-PdxFileSection" to generate them.
     
    .PARAMETER Rule
        The rules to apply to each section.
        Currently supported rule-types:
        - Insert: Adds extra content above or below existing lines.
        - Replace: Use regex replacement on the entire section.
 
        Examples:
        > Insert:
        @{
            Type = "Insert"
            Scope = 'Above'
            Line = 'station_modifier = {'
            Text = '# Will turn the base into a juggernaut'
        }
 
        Notes:
        The "Text"-Field can have any number of lines of text, as needed.
         
        > Replace:
        @{
            Type = 'Replace'
            Old = 'max = 320 # 20 \* 16'
            New = 'max = 3200 # 200 * 16'
        }
 
        Notes:
        This replacement uses regular expressions. Mind your special characters.
        It applies the ruleset as used by C#.
     
    .PARAMETER PassThru
        Return the processed section object.
        By default, this command returns nothing, merely modifying the sections passed to it.
     
    .EXAMPLE
        PS C:\> Update-PdxFileSection -Section $allComponents -Rule $prerequisites
 
        Updates all the sections in $allComponents based on the rules in $prerequisites.
        - See description on "-Rule" parameter to see how rules should be defined.
        - See "Read-PdxFileSection" command for how to obtain sections.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [object[]]
        $Section,

        [hashtable[]]
        $Rule,

        [switch]
        $PassThru
    )

    process {
        foreach ($entry in $Section) {
            :rules foreach ($ruleItem in $Rule) {
                switch ($ruleItem.Type) {
                    'Insert' {
                        $index = $entry.Lines.IndexOf($ruleItem.Line)
                        if ($index -lt 0) { $index = $entry.LinesCompact.IndexOf($ruleItem.Line) }
                        if ($index -lt 0) {
                            Write-PSFMessage -Level Warning -Message "Error updating {0}: Line not found: {1}" -StringValues $entry.Name, $ruleItem.Line -Target $entry
                            continue rules
                        }

                        $start = $index
                        if ('Above' -eq $ruleItem.Scope) { $start = $start - 1 }

                        $newLines = $entry.Lines[0..$start] + ($ruleItem.Text -split "[`n`r]+") + $entry.Lines[($start + 1)..($entry.Lines.Count - 1)]
                        $entry.Lines = $newLines
                        $entry.LinesCompact = $newLines | Get-SubString -Trim "`t "
                        $entry.Text = $NewLines -join "`n"
                    }
                    'Replace' {
                        if ($ruleItem.FullText) {
                            $newText = $entry.Text -replace $ruleItem.Old, $ruleItem.New
                            $newLines = $newText -split '[\n\r]+'
                        }
                        else {
                            $newLines = $entry.Lines -replace $ruleItem.Old, $ruleItem.New
                        }
                        $entry.Lines = $newLines
                        $entry.LinesCompact = $newLines | Get-SubString -Trim "`t "
                        $entry.Text = $NewLines -join "`n"
                    }
                    default {
                        Write-PSFMessage -Level Warning -Message 'Error processing rule: Unexpected rule type: {0}' -StringValues $ruleItem.Type -Data @{ Rule = $ruleItem } -Target $entry
                    }
                }
            }
            if ($PassThru) { $entry }
        }
    }
}

function Add-PdxLocalizedString {
    <#
    .SYNOPSIS
        Adds a localization entry into a previously created localization hashtable.
     
    .DESCRIPTION
        Adds a localization entry into a previously created localization hashtable.
        This allows defining a default text for languages otherwise not defined (or just simply going mono-lingual).
     
    .PARAMETER Key
        The string key to provide text for.
     
    .PARAMETER Text
        The default text to apply.
     
    .PARAMETER Localized
        Any additional string mappings for different languages.
     
    .PARAMETER Strings
        The central strings dictionary.
        Use New-PdxLocalizedString to generate one.
     
    .EXAMPLE
        PS C:\> Add-PdxLocalizedString -Key $traitLocName -Text 'Suicide Pact' -Localized $trait.Localized -Strings $strings
 
        Adds the string in $traitLocName with a default text of "Suicide Pact" and whatever we cared to localize to the $strings dictionary
    #>

    [CmdletBinding()]
    param (
        [string]
        $Key,
        
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $Text,

        [AllowNull()]
        [hashtable]
        $Localized,

        [hashtable]
        $Strings
    )

    if (-not $Strings) { return }
    $languages = $Strings.Keys

    foreach ($language in $languages) {
        $Strings[$language][$Key] = $Text
        if (-not $Localized) { continue }
        if ($Localized[$language]) {
            $Strings[$language][$Key] = $Localized[$language]
        }
    }
}

function Export-PdxLocalizedString {
    <#
    .SYNOPSIS
        Creates language files from a localized strings hashtable.
     
    .DESCRIPTION
        Creates language files from a localized strings hashtable.
        Expects a hashtable with one nested hashtable per language, using the system language name as key.
 
        Create a new strings hashtable with New-PdxLocalizedString
        Add entries with Add-PdxLocalizedString
     
    .PARAMETER Strings
        A hashtable with one nested hashtable per language, using the system language name as key.
     
    .PARAMETER ModRoot
        The root path, where the mod begins.
     
    .PARAMETER Name
        The basic name of the strings file to be created.
 
    .PARAMETER ToLower
        Whether localization keys should be converted to lowercase
     
    .EXAMPLE
        PS C:\> Export-PdxLocalizedString -Strings $strings -ModRoot $resolvedPath -Name $Definition.Name
         
        Exports the localization stored in $strings to the correct place under the mod root, with the name provided.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [hashtable]
        $Strings,

        [Parameter(Mandatory = $true)]
        [string]
        $ModRoot,
        
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [switch]
        $ToLower
    )
    process {
        foreach ($language in $Strings.Keys) {
            $lines = @()
            foreach ($pair in $strings[$language].GetEnumerator()) {
                if ($ToLower) { $lines += ' {0}:0 "{1}"' -f $pair.Key.ToLower(), ($pair.Value -replace '"',"'") }
                else { $lines += ' {0}:0 "{1}"' -f $pair.Key, ($pair.Value -replace '"',"'") }
            }
            $lines = @("l_$($language):") + ($lines | Sort-Object)

            $localizedText = $lines -join "`n"
            $encoding = [System.Text.UTF8Encoding]::new($true)
            $outFolder = Join-Path -Path $ModRoot -ChildPath "localisation/$language"
            if (-not (Test-Path -Path $outFolder)) { $null = New-Item -Path $outFolder -ItemType Directory -Force }
            $outPath = Join-Path -Path $ModRoot -ChildPath "localisation/$language/$($Name)_l_$($language).yml"
            [System.IO.File]::WriteAllText($outPath, $localizedText, $encoding)
        }
    }
}

function New-PdxLocalizedString {
    <#
    .SYNOPSIS
        Creates a new set of localization data for a Paradox game mod.
     
    .DESCRIPTION
        Creates a new set of localization data for a Paradox game mod.
        This can be used to conveniently build up localization data as you generate mod content,
        and later export it as localization files.
 
        Mostly used to enable mods to keep content and localization together.
        Especially when you are not planning to localize anyway.
 
        Add entries with Add-PdxLocalizedString
        Export to disk with Export-PdxLocalizedString
     
    .EXAMPLE
        PS C:\> New-PdxLocalizedString
         
        Generates a hashtable, mapping all the supported languages.
        There really is nothing else to it.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [OutputType([hashtable])]
    [CmdletBinding()]
    param ()
    process {
        @{
            braz_por = @{ }
            english = @{ }
            french = @{ }
            german = @{ }
            japanese = @{ }
            polish = @{ }
            russian = @{ }
            simp_chinese = @{ }
            spanish = @{ }
        }
    }
}

Register-PSFTeppScriptblock -Name 'Paradox.Modding.ChildFolder' -ScriptBlock {
    $root = '.'
    if ($fakeBoundParameter.Path) {
        $root = $fakeBoundParameter.Path
    }

    Get-ChildItem -Path $root -Directory | ForEach-Object {
        @{ Text = $_.Name; Tooltip = $_.FullName}
    }
} -Global

Register-PSFTeppScriptblock -Name 'Paradox.Modding.BuildExtension.Tags' -ScriptBlock {
    (Get-PdxBuildExtension).Tag | Write-Output | Sort-Object -Unique
} -Global

Register-PSFTeppScriptblock -Name 'Paradox.Modding.BuildExtension.Name' -ScriptBlock {
    (Get-PdxBuildExtension).Name | Sort-Object -Unique
} -Global

# Storage for Game-Specific build extensions.
# These allow modules to extend the default build steps without requiring the module author to handle them in their build script (if any).
$script:buildExtensions = @{}