39 Commits

Author SHA1 Message Date
8f8233ab0b Добавлены локальный env и Telegram-уведомления для CI 2026-04-11 20:03:24 +03:00
af1fd046d4 Уточнено правило завершения работы в fix и feat ветках 2026-04-11 19:35:19 +03:00
d82c627bb1 Подготовлена поддержка тестовых релизных тегов 2026-04-11 18:52:08 +03:00
d17bd723e3 Добавлены уведомления о релизной сборке в Telegram 2026-04-11 18:23:49 +03:00
fe8c50ff80 Поднята версия мода до v0.2.7 2026-04-11 00:19:07 +03:00
f9289b4384 Слита ветка feat/update-translation в main 2026-04-11 00:16:06 +03:00
5ac424770d Merge remote-tracking branch 'origin/main' into feat/update-translation
# Conflicts:
#	Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
2026-04-11 00:13:26 +03:00
a29c22af12 Исправлены формулировки перевода иллриггера 2026-04-11 00:11:52 +03:00
b684bf195e Merge pull request #1 from Underslumber/fix/update-translation
Fix/update translation
2026-04-11 00:02:05 +03:00
39ac7a54ce Обновлен перевод класса Illrigger 2026-04-11 00:01:05 +03:00
78a9840349 Обновлен перевод и исправлено применение JSON-правок 2026-04-10 23:55:57 +03:00
92c78f0813 Уточнены правила AGENTS.md по ссылкам, триггерам CI и approval gates 2026-04-10 22:53:40 +03:00
07c7fc8aa7 Поднята версия релиза до v0.2.6 2026-04-10 22:52:43 +03:00
cb37f422ac Merge remote-tracking branch 'origin/main' 2026-04-10 22:50:04 +03:00
72bab59520 Fix 2026-04-10 22:29:04 +03:00
1ca6d579da Слияние изменений из Gitea 2026-04-10 19:38:03 +03:00
6ede25dc35 Обновлена версия мода до v0.2.4 2026-04-10 15:26:10 +03:00
de85438afe Добавлен GitHub Actions для сборки и релизов 2026-04-10 14:53:04 +03:00
111cf8c269 Восстановлены потерянные правила при рефакторинге AGENTS.md 2026-04-10 12:37:18 +03:00
d2299199ba Исправлена кодировка meta.lsx и выпущен hotfix v0.2.5
All checks were successful
Build Mod Package / build (push) Successful in 4s
2026-04-10 12:23:12 +03:00
ec062817dc Поднята версия релиза до v0.2.4
All checks were successful
Build Mod Package / build (push) Successful in 11s
2026-04-10 12:14:31 +03:00
f56e10748e Уточнен регистр терминов отдыха 2026-04-10 12:06:47 +03:00
ac1bd03426 Обновить автора на Underslumber Team в meta.lsx и scripts/build.ps1 2026-04-10 01:47:13 +03:00
07f557ca30 Обновлено описание проекта в README 2026-04-09 21:41:42 +03:00
7f189aa741 Ужесточены сценарии обновления перевода для AI-агента 2026-04-09 16:03:02 +03:00
d048a33c55 Уточнил процесс обновления перевода в ACTIONS.md 2026-04-09 13:18:51 +03:00
4e99dfdd92 Объединил автоматизацию локального обновления перевода 2026-04-09 13:03:40 +03:00
5d54da9048 Автоматизировал локальное обновление перевода 2026-04-09 13:02:40 +03:00
d96db4e1f0 Уточнил правило для повторных pending-вопросов 2026-04-09 12:14:36 +03:00
c40712701c Исправлен XPath Version64 в правилах релиза AGENTS.md 2026-04-09 11:59:29 +03:00
c9595312ab Уточнены правила ссылок и переключения веток в AGENTS.md 2026-04-09 11:55:17 +03:00
91e12e4ba1 Повышена версия мода до v0.2.3 в ModuleInfo Version64
All checks were successful
Build Mod Package / build (push) Successful in 16s
2026-04-09 11:35:03 +03:00
921a5a3156 Обновлён перевод russian.xml и синхронизирована зависимость в meta.lsx 2026-04-09 11:32:32 +03:00
2ab32b258c Оптимизированы правила подтверждения действий 2026-04-09 11:23:49 +03:00
175c1dbaed Добавлено предложение обновления зависимости после обновления перевода 2026-04-09 11:16:26 +03:00
e9bfbfe74f Уточнены правила по веткам и предварительному показу небольших правок 2026-04-09 11:13:08 +03:00
6782cfcd87 Merge branch 'feat/sync-parent-meta' 2026-04-09 10:58:42 +03:00
eaf84ad605 Скрипт синхронизации parent meta переведен на загрузку из git 2026-04-09 10:58:18 +03:00
65e3f5b48e Добавлены action и скрипт синхронизации parent meta, уточнены правила подтверждения 2026-04-09 10:54:30 +03:00
19 changed files with 1759 additions and 315 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
BOT_TOKEN=
TG_CHAT_ID=
TG_THREAD_ID=

View File

@@ -13,6 +13,9 @@ jobs:
build: build:
runs-on: runs-on:
- win11 - win11
env:
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
TG_THREAD_ID: ${{ secrets.TG_THREAD_ID }}
defaults: defaults:
run: run:
@@ -40,6 +43,7 @@ jobs:
} }
git -c "http.extraHeader=Authorization: Basic $authBasic" fetch --depth 1 origin $repoRef git -c "http.extraHeader=Authorization: Basic $authBasic" fetch --depth 1 origin $repoRef
git -c "http.extraHeader=Authorization: Basic $authBasic" fetch --force --tags origin
git checkout --force FETCH_HEAD git checkout --force FETCH_HEAD
if (-not (Test-Path -LiteralPath "Mods\\DnD 5.5e AIO Russian\\Localization\\Russian\\russian.xml")) { if (-not (Test-Path -LiteralPath "Mods\\DnD 5.5e AIO Russian\\Localization\\Russian\\russian.xml")) {
@@ -49,6 +53,17 @@ jobs:
New-Item -ItemType Directory -Path ".tools\\lslib" -Force | Out-Null New-Item -ItemType Directory -Path ".tools\\lslib" -Force | Out-Null
New-Item -ItemType Directory -Path "build" -Force | Out-Null New-Item -ItemType Directory -Path "build" -Force | Out-Null
- name: Notify Telegram about build start
if: startsWith(gitea.ref, 'refs/tags/v')
continue-on-error: true
env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$runUrl = "${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
$text = "⏳ <b>Старт сборки релиза</b>`n<b>Репозиторий:</b> <code>${{ gitea.repository }}</code>`n<b>Тег:</b> <code>${{ gitea.ref_name }}</code>`n<a href=`"$runUrl`">Открыть лог сборки</a>"
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text -DisableNotification
- name: Download latest LSLib release - name: Download latest LSLib release
run: | run: |
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -109,6 +124,7 @@ jobs:
throw "Release archive was not found at '$zipPath'." throw "Release archive was not found at '$zipPath'."
} }
$isPrerelease = $tagName -match '^v\d+\.\d+\.\d+-'
$owner = $repoParts[0] $owner = $repoParts[0]
$repo = $repoParts[1] $repo = $repoParts[1]
$apiBase = "$serverUrl/api/v1/repos/$owner/$repo" $apiBase = "$serverUrl/api/v1/repos/$owner/$repo"
@@ -137,10 +153,19 @@ jobs:
name = $tagName name = $tagName
target_commitish = "${{ gitea.sha }}" target_commitish = "${{ gitea.sha }}"
draft = $false draft = $false
prerelease = $false prerelease = $isPrerelease
} | ConvertTo-Json } | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Uri "$apiBase/releases" -Headers $headers -ContentType "application/json" -Body $releaseBody $release = Invoke-RestMethod -Method Post -Uri "$apiBase/releases" -Headers $headers -ContentType "application/json" -Body $releaseBody
} elseif ($release.prerelease -ne $isPrerelease) {
$releaseBody = @{
tag_name = $tagName
target_commitish = "${{ gitea.sha }}"
draft = $false
prerelease = $isPrerelease
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Patch -Uri "$apiBase/releases/$($release.id)" -Headers $headers -ContentType "application/json" -Body $releaseBody
} }
$existingAsset = $null $existingAsset = $null
@@ -158,3 +183,25 @@ jobs:
} }
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($assetName))" -Headers $uploadHeaders -ContentType "application/octet-stream" -InFile $zipPath Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($assetName))" -Headers $uploadHeaders -ContentType "application/octet-stream" -InFile $zipPath
- name: Notify Telegram about build success
if: success() && startsWith(gitea.ref, 'refs/tags/v')
continue-on-error: true
env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$releaseUrl = "${{ gitea.server_url }}/${{ gitea.repository }}/releases/tag/${{ gitea.ref_name }}"
$text = "🏁 <b>Релиз собран успешно</b>`n<b>Тег:</b> <code>${{ gitea.ref_name }}</code>`n<a href=`"$releaseUrl`">Открыть готовый релиз</a>"
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text -DisableNotification
- name: Notify Telegram about build failure
if: failure() && startsWith(gitea.ref, 'refs/tags/v')
continue-on-error: true
env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$runUrl = "${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
$text = "❌ <b>Сборка релиза завершилась ошибкой</b>`n<b>Тег:</b> <code>${{ gitea.ref_name }}</code>`n<a href=`"$runUrl`">Открыть лог сборки</a>"
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text

186
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,186 @@
name: Build Mod Package
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
environment: Configure TgBot
env:
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
TG_THREAD_ID: ${{ secrets.TG_THREAD_ID }}
defaults:
run:
shell: pwsh
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Prepare workspace
run: |
$ErrorActionPreference = "Stop"
if (-not (Test-Path -LiteralPath "Mods\\DnD 5.5e AIO Russian\\Localization\\Russian\\russian.xml")) {
throw "Repository sources are not available in the runner workspace."
}
New-Item -ItemType Directory -Path ".tools\\lslib" -Force | Out-Null
New-Item -ItemType Directory -Path "build" -Force | Out-Null
- name: Resolve version tag
id: vars
run: |
$ErrorActionPreference = "Stop"
$versionTag = ""
$isPrerelease = "false"
if ($env:GITHUB_REF -like "refs/tags/v*") {
$versionTag = $env:GITHUB_REF_NAME
if ($versionTag -match '^v\d+\.\d+\.\d+-') {
$isPrerelease = "true"
}
}
"version_tag=$versionTag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"is_prerelease=$isPrerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Notify Telegram about build start
if: startsWith(github.ref, 'refs/tags/v')
continue-on-error: true
env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$target = if ("${{ steps.vars.outputs.version_tag }}") { "${{ steps.vars.outputs.version_tag }}" } else { "${{ github.ref_name }}" }
$runUrl = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
$text = "⏳ <b>Старт сборки релиза</b>`n<b>Репозиторий:</b> <code>${{ github.repository }}</code>`n<b>Тег:</b> <code>$target</code>`n<a href=`"$runUrl`">Открыть лог сборки</a>"
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text -DisableNotification
- name: Download latest LSLib release
run: |
$ErrorActionPreference = "Stop"
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/Norbyte/lslib/releases/latest"
$asset = $release.assets | Where-Object { $_.name -match '\.zip$' } | Select-Object -First 1
if (-not $asset) {
throw "Could not find a downloadable LSLib zip asset in the latest release."
}
Invoke-WebRequest -UseBasicParsing -Uri $asset.browser_download_url -OutFile ".tools/lslib/lslib.zip"
Expand-Archive -LiteralPath ".tools/lslib/lslib.zip" -DestinationPath ".tools/lslib" -Force
- name: Build .pak
run: |
$ErrorActionPreference = "Stop"
$divine = Get-ChildItem -Path ".tools\\lslib" -Recurse -File |
Where-Object { $_.Name -ieq "Divine.exe" } |
Select-Object -First 1
if (-not $divine) {
throw "Divine.exe was not found in the downloaded LSLib release."
}
& ".\\scripts\\build.ps1" -DivinePath $divine.FullName -Workspace "${{ github.workspace }}" -VersionTag "${{ steps.vars.outputs.version_tag }}"
- name: Show build result
run: |
$ErrorActionPreference = "Stop"
$archiveBaseName = "DnD 5.5e AIO Russian"
if ($env:GITHUB_REF -like "refs/tags/v*") {
$archiveBaseName = "DnD 5.5e AIO Russian $env:GITHUB_REF_NAME"
}
$zipPath = [System.IO.Path]::GetFullPath((Join-Path "${{ github.workspace }}" "build/$archiveBaseName.zip"))
Get-ChildItem "build/DnD 5.5e AIO Russian.pak", "build/info.json", $zipPath |
Select-Object FullName, Length, LastWriteTime
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: bg3-dnd55e-russian-localization-${{ github.run_number }}
path: build/*
if-no-files-found: error
- name: Create or update GitHub release
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$tagName = "${{ github.ref_name }}"
$zipPath = Join-Path $env:GITHUB_WORKSPACE "build\\DnD 5.5e AIO Russian $tagName.zip"
$isPrerelease = "${{ steps.vars.outputs.is_prerelease }}" -eq "true"
if (-not (Test-Path -LiteralPath $zipPath)) {
throw "Release archive was not found at '$zipPath'."
}
gh release view $tagName --repo "${{ github.repository }}" *> $null
if ($LASTEXITCODE -ne 0) {
$args = @(
"release", "create", $tagName, $zipPath,
"--repo", "${{ github.repository }}",
"--title", $tagName,
"--notes", ""
)
if ($isPrerelease) {
$args += "--prerelease"
}
gh @args
}
else {
gh release upload $tagName $zipPath `
--repo "${{ github.repository }}" `
--clobber
$releaseId = gh release view $tagName `
--repo "${{ github.repository }}" `
--json id `
--jq ".id"
$releaseBodyPath = Join-Path $env:RUNNER_TEMP "release-body.json"
@{
name = $tagName
prerelease = $isPrerelease
} | ConvertTo-Json -Compress | Set-Content -LiteralPath $releaseBodyPath -Encoding utf8
gh api `
--method PATCH `
-H "Accept: application/vnd.github+json" `
"/repos/${{ github.repository }}/releases/$releaseId" `
--input $releaseBodyPath
}
- name: Notify Telegram about build success
if: success() && startsWith(github.ref, 'refs/tags/v')
continue-on-error: true
env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$releaseUrl = "${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
$text = "🏁 <b>Релиз собран успешно</b>`n<b>Тег:</b> <code>${{ github.ref_name }}</code>`n<a href=`"$releaseUrl`">Открыть готовый релиз</a>"
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text -DisableNotification
- name: Notify Telegram about build failure
if: failure() && startsWith(github.ref, 'refs/tags/v')
continue-on-error: true
env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$runUrl = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
$text = "❌ <b>Сборка релиза завершилась ошибкой</b>`n<b>Тег:</b> <code>${{ github.ref_name }}</code>`n<a href=`"$runUrl`">Открыть лог сборки</a>"
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text

5
.gitignore vendored
View File

@@ -1,6 +1,11 @@
build/ build/
build-stage* build-stage*
.tools/ .tools/
.cache/
.env
.env.local
.env.*
!.env.example
*.pak *.pak
*.tmp *.tmp
*.temp *.temp

View File

@@ -1,6 +1,6 @@
# ACTIONS.md # ACTIONS.md
VERSION: 2 VERSION: 3
MODE: machine-first MODE: machine-first
LANG: ru LANG: ru
@@ -20,6 +20,7 @@ EXECUTION_BASELINE:
- minimal_non_breaking_changes: true - minimal_non_breaking_changes: true
- steps_count_range: [3, 7] - steps_count_range: [3, 7]
- before_commit_push: request_user_approval - before_commit_push: request_user_approval
- prefer_existing_repo_scripts_over_manual_work: true
REPORT_FORMAT: REPORT_FORMAT:
- done - done
@@ -28,6 +29,48 @@ REPORT_FORMAT:
- remaining - remaining
ACTIONS: ACTIONS:
translation:diff:
intent: fetch_upstream_english_and_compare_with_ru
inputs:
- AGENTS.md::Canonical Paths::Upstream English reference
- Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
plan:
- run_scripts/get-upstream-english.ps1_and_wait_until_output_exists
- run_scripts/compare-translation.ps1_after_upstream_download_only
- classify_diff_into_missing_changed_stale
- write_machine_readable_and_markdown_reports_for_local_review
checks:
- xml_valid
- cache_path_gitignored
- local_only_no_ci_workflow_required
- translation_steps_not_parallelized_when_file_dependency_exists
outputs:
- .cache/upstream/english.xml
- build/translation-diff/summary.json
- build/translation-diff/summary.md
- build/translation-diff/candidates.json
translation:apply:
intent: apply_translation_edits_to_russian_xml
inputs:
- Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
- build/translation-diff/candidates.json
- prepared_update_texts_for_updates_and_optional_adds
plan:
- create_temporary_copy_of_russian_xml
- load_candidate_edit_file_and_temporary_xml
- fail_if_add_entry_has_empty_text
- apply_updates_and_optional_new_entries_by_contentuid
- write_utf8_bom_xml_to_temporary_copy
- validate_temporary_xml_via_separate_script
- replace_original_russian_xml_after_successful_validation
- report_changed_entries
checks:
- xml_valid
- contentuid_uniqueness_preserved
- only_requested_entries_changed
- no_partial_replace_on_validation_failure
outputs:
- Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
translation:update: translation:update:
intent: sync_ru_translation_with_upstream intent: sync_ru_translation_with_upstream
inputs: inputs:
@@ -35,18 +78,27 @@ ACTIONS:
- glossary/glossary.normalized.json - glossary/glossary.normalized.json
- AGENTS.md::Canonical Paths::Upstream English reference - AGENTS.md::Canonical Paths::Upstream English reference
plan: plan:
- compare_en_vs_ru_by_keys - run_translation:diff_sequentially
- classify_diff_into_new_changed_stale_candidate - if_summary_has_no_missing_no_version_mismatch_no_stale_report_translation_up_to_date_and_stop
- update_ru_for_new_and_changed_using_glossary - if_diff_exists_stop_after_generating_build/translation-diff/candidates.json_until_prepared_edits_are_provided_explicitly
- validate_xml_structure_and_service_attributes - review_build/translation-diff/candidates.json_before_apply
- prepare_delta_summary_counts - reuse_glossary_for_term_consistency_when_preparing_texts
- run_translation:apply_only_after_candidate_texts_are_filled_and_explicit_edits_path_is_passed
checks: checks:
- xml_valid - xml_valid
- glossary_consistency - glossary_consistency
- scope_limited_to_localization_and_allowed_metadata - scope_limited_to_localization_and_allowed_metadata
- no_upstream_download_compare_race_condition
outputs: outputs:
- message: translation_up_to_date
- build/translation-diff/summary.json
- build/translation-diff/summary.md
- build/translation-diff/candidates.json
- Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml - Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
- optional: Mods/DnD 5.5e AIO Russian/meta.lsx (release-only) - optional: Mods/DnD 5.5e AIO Russian/meta.lsx (release-only)
after_success:
- suggest_action: meta:sync-parent
reason: "Обновить версию зависимости из родительского мода (актуальный Version64 и связанные поля зависимости)."
action:report: action:report:
intent: unified_task_report intent: unified_task_report
@@ -65,3 +117,21 @@ ACTIONS:
- no_unverified_claims - no_unverified_claims
outputs: outputs:
- final_user_report - final_user_report
meta:sync-parent:
intent: sync_dependency_moduleshortdesc_from_parent_meta
inputs:
- parent_meta_git_url (optional; defaults to upstream)
- Mods/DnD 5.5e AIO Russian/meta.lsx
plan:
- read_parent_moduleinfo_fields
- validate_required_fields_folder_md5_name_publishhandle_uuid_version64
- update_target_dependencies_moduleshortdesc_fields
- validate_xml_structure
- report_changed_fields
checks:
- xml_valid
- required_parent_fields_present
- only_dependencies_moduleshortdesc_changed
outputs:
- Mods/DnD 5.5e AIO Russian/meta.lsx

293
AGENTS.md
View File

@@ -1,85 +1,236 @@
# AGENTS.md # AGENTS.md
## General Rules (MUST) ## Execution Model (MUST)
- Read this file first; treat as system-level constraints.
- Priority:
1. User instructions
2. AGENTS.md
3. Existing code/style
4. Best practices
- Prefer minimal, non-breaking changes.
- Do not introduce unnecessary abstractions.
### Git Collaboration Policy (General) ---
- Ask user permission before commit.
- After approval: commit and push immediately.
- At the start of each new fix/feature task: pause task execution, propose switching to a dedicated `fix/*` or `feat/*` branch, and continue only after explicit user decision on branch change necessity.
- After finishing work in `fix/*` or `feat/*`: propose either
1. creating an MR into `main`, or
2. merging to `main` immediately and deleting the `fix/*`/`feat/*` branch.
- If push fails: retry up to 2 more times with 3s pause.
- Never auto-commit/auto-push without explicit user approval.
### Cleanup (General) ## Communication (MUST)
- Do not leave temporary/debug artifacts in repo. - Answer first, then request approval if needed.
- Remove additional debug/temp dirs unless user asked to keep them. - Concise, meaningful, no filler.
- Do not end response with only procedural choice.
### Rules Maintenance (General) Approval/clarification:
- For changes to rules files (`AGENTS.md`, `ACTIONS.md`): prefer optimized, compressed edits for AI-agent execution (machine-readable, unambiguous). - ask once, no repetition
- Keep rule updates minimal and non-duplicative: merge overlapping points, remove redundancy, preserve intent. - binary → yes/no
- multiple → numbered options + brief context
## Project-Specific Rules (MUST) - File links in repo docs/checklists: relative paths, `/`, spaces as `%20`.
- In assistant UI responses, use the link format required by the execution environment; include relative path text when possible.
### Scope ---
- Repository purpose: standalone Russian localization mod only.
- Allowed domain: localization content + packaging/release metadata.
- Forbidden: gameplay logic, Script Extender content, unrelated assets.
- Keep repository source-only.
- Never commit `.pak` or temporary build artifacts.
### Canonical Paths ## Git Workflow (MUST)
- Mod sources: `Mods/DnD 5.5e AIO Russian` - Never commit/push without explicit user approval.
- Russian localization: `Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml` - After approval → commit + push immediately.
- Mod metadata: `Mods/DnD 5.5e AIO Russian/meta.lsx` - Commit messages: Russian, factual (what was done).
- Build script (single source of build truth): `scripts/build.ps1` - Branch (`fix/*` or `feat/*`): ask once before the first file-changing task that may lead to commit; reuse decision for all subsequent tasks in same dialogue.
- CI workflow: `.gitea/workflows/build.yml`
- Glossary (primary terminology reference): `glossary/glossary.normalized.json`
- Action catalog and command playbooks: `ACTIONS.md`
- Upstream English reference: `https://github.com/Yoonmoonsik/dnd55e/blob/main/Mods/DnD2024_897914ef-5c96-053c-44af-0be823f895fe/Localization/English/english.xml`
### Packaging Invariants After work in `fix/*` or `feat/*`:
- `.pak` must contain only BG3 mod structure under `Mods/...`. 1. create PR/MR targeting `main`
- Required content in `.pak`: 2. merge changes into `main` and delete the source branch
- `Mods/DnD 5.5e AIO Russian/meta.lsx`
- `Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml`
- Must not leak into `.pak`: `.git`, `.gitea`, `scripts`, `tools`, `.tools`, `build`, staging dirs.
- Staging for packaging must be in `%TEMP%`, not in dot-prefixed repo dirs.
### Build/CI Contract Push failure:
- CI workflow stays thin: - retry ≤2 times, 3s delay
1. prepare workspace
2. download Divine
3. call `scripts/build.ps1`
4. publish tag archive
- Expected build outputs:
- `build/DnD 5.5e AIO Russian.pak`
- `build/info.json`
- `build/DnD 5.5e AIO Russian <tag>.zip` (for tag builds)
- Release ZIP must include only `.pak` + `info.json`.
- CI triggers: tag `v*` and manual dispatch; not every push to `main`.
### Version/Release Rules Release link:
- Read release version only from `save/region/node[@id="ModuleSettings"]/children/node[@id="ModuleInfo"]/attribute[@id="Version64"]` via explicit XML parsing. - provide `[version](url)` immediately after tag push, without waiting for CI
- `PublishVersion` must not be changed during release preparation.
- Release tag must match the source-of-truth version.
- Decision logic before tagging:
1. If `ModuleInfo/Version64` was manually changed (e.g. BG3 Toolkit), use matching tag and release.
2. If `ModuleInfo/Version64` equals latest released version, bump version first (e.g. `scripts/set-version.ps1 -VersionTag <tag>`), commit, then create/push tag.
- `scripts/build.ps1` derives release `Version64` from tag and writes it to generated `info.json` and staged `meta.lsx`.
### info.json Contract ---
- Top-level keys: `Mods`, `MD5`.
- Per-mod keys: `Author`, `Name`, `Folder`, `Version`, `Description`, `UUID`, `Created`, `Dependencies`, `Group`.
- `Dependencies` is an array of UUIDs.
- Current dependency UUID: `897914ef-5c96-053c-44af-0be823f895fe`.
### Git Collaboration Policy (Project-Specific) ## Scope (MUST)
- Commit messages and comments: Russian. - Repo = Russian localization mod only.
- Commit message content: what was done (not what should be done).
- If changes affect `.pak` contents or build/release flow: propose releasing next version.
- For released versions in user-facing messages: provide direct archive link in Markdown format `[version](url)` when derivable (acceptable immediately after tag push, even before CI finishes).
### Cleanup (Project-Specific) Allowed:
- Ignored/temp patterns include: `build/`, `build-stage*`, `.tools/`, `*.pak`. - localization content
- packaging/release metadata
Forbidden:
- gameplay logic
- Script Extender
- unrelated assets
- Repo must remain source-only.
- Never commit `.pak` or build artifacts.
---
## Paths (MUST)
- Mod: `Mods/DnD 5.5e AIO Russian`
- Localization: `Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml`
- Metadata: `Mods/DnD 5.5e AIO Russian/meta.lsx`
- Build: `scripts/build.ps1` _(single source of build truth)_
- CI: `.gitea/workflows/build.yml`
- Glossary: `glossary/glossary.normalized.json` _(primary terminology reference)_
- Actions: `ACTIONS.md`
- Local env template: `.env.example`
- Local env file: `.env.local`
- Upstream EN reference: `https://github.com/Yoonmoonsik/dnd55e/blob/main/Mods/DnD2024_897914ef-5c96-053c-44af-0be823f895fe/Localization/English/english.xml`
---
## Packaging (MUST)
- `.pak` contains ONLY `Mods/...`
Required:
- `meta.lsx`
- `russian.xml`
Forbidden in `.pak`:
- `.git`, `.gitea`
- `scripts`, `tools`, `.tools`
- `build`, staging dirs
Staging:
- use `%TEMP%`
- not inside repo
---
## Build & CI (MUST)
Flow:
1. prepare
2. download Divine
3. run `scripts/build.ps1`
4. publish
Outputs:
- `build/*.pak`
- `build/info.json`
- `build/*.zip` (tag only)
Release ZIP:
- only `.pak` + `info.json`
Triggers:
- automatic: push tag `v*`
- manual: workflow_dispatch
- branch pushes without tag MUST NOT publish release artifacts
---
## Versioning (CRITICAL)
Source of truth:
`ModuleInfo/Version64` — read via explicit XML parsing:
`save/region[@id="Config"]/node[@id="root"]/children/node[@id="ModuleInfo"]/attribute[@id="Version64"]`
Rules:
- do not change `PublishVersion`
- tag MUST match version
- tag formats:
- stable: `vX.Y.Z` -> `Version64 = X.Y.Z.0`
- suffixed: `vX.Y.Z-suffix` -> `Version64 = X.Y.Z.N`
- for suffixed tags, suffix affects tag/release channel only and is NOT encoded in `Version64`
- for suffixed tags on the same base version `X.Y.Z`, increment `build` (`N`) by counting prior released tags `vX.Y.Z-*`; current release uses the next value starting from `1`
- stable tag without suffix always uses `build = 0`, even if suffixed releases for the same base version already existed
Before tag:
1. if version already changed → use it
2. if same as last → bump:
`scripts/set-version.ps1 -VersionTag <tag>`
`build.ps1`:
- derives version from tag
- writes to `info.json` + staged `meta.lsx`
Conflict resolution (MUST):
- before release, `Version64` in `meta.lsx` MUST equal target tag version
- if mismatch, run `scripts/set-version.ps1 -VersionTag <tag>` and re-check
- if still mismatch, release is blocked
---
## info.json (MUST)
Root:
- `Mods`, `MD5`
Per mod:
- Author, Name, Folder, Version
- Description, UUID, Created
- Dependencies (array), Group
Dependency UUID:
`897914ef-5c96-053c-44af-0be823f895fe`
---
## Guardrails (MUST)
Before commit:
- scope valid (localization/metadata only)
- no forbidden content
- no build artifacts (`.pak`, `build/`, staging)
- no temp/debug artifacts; ignored patterns MUST be present in `.gitignore`: `build/`, `build-stage*`, `.tools/`, `*.pak`
- packaging invariants intact
- version consistent (if applicable)
Before push:
- explicit user approval
- commit message valid (RU, factual)
Before release:
- version == tag
- version bumped if needed
- CI/build contract valid
- outputs correct (no extra files)
---
## Release & Changelog (MUST)
- Every release MUST include changelog.
Changelog:
- language: Russian
- concise, user-facing
- describe WHAT changed
- group logically
Sources:
- prefer diff over commits
Diff rules:
- inspect real file changes
Localization (`russian.xml`):
- added / changed / removed strings
- summarize user-visible impact (UI, spells, descriptions)
Metadata / CI:
- describe effect, not raw edits
Large diff:
- group + summarize
If no visible changes:
- state "техническое обновление"
Before release:
- generate changelog draft
- ask for approval
Approval gates:
- Gate A: explicit approval for commit/push (code/content changes)
- Gate B: explicit approval for release publish (after changelog draft)
Release message:
- version
- changelog
- `[version](url)` if derivable
Do not:
- invent changes
- include internal noise
---
## Rules Maintenance (MUST)
- Changes to `AGENTS.md` / `ACTIONS.md`: prefer compressed, machine-readable edits.
- Keep updates minimal and non-duplicative: merge overlapping points, remove redundancy, preserve intent.

File diff suppressed because it is too large Load Diff

View File

@@ -18,10 +18,10 @@
</children> </children>
</node> </node>
<node id="ModuleInfo"> <node id="ModuleInfo">
<attribute id="Author" type="LSString" value="MikhailRaw"/> <attribute id="Author" type="LSString" value="Underslumber Team"/>
<attribute id="CharacterCreationLevelName" type="FixedString" value=""/> <attribute id="CharacterCreationLevelName" type="FixedString" value=""/>
<attribute id="Description" type="LSString" value="Русский перевод мода DnD 5.5e All-in-One BEYOND. Перевод ещё в разработке: AI помогает быстро обновлять тексты, а финальные правки и качество мы проверяем вручную."/> <attribute id="Description" type="LSString" value="Русский перевод мода DnD 5.5e All-in-One BEYOND. Перевод ещё в разработке: AI помогает быстро обновлять тексты, а финальные правки и качество мы проверяем вручную."/>
<attribute id="FileSize" type="uint64" value="0"/> <attribute id="FileSize" type="uint64" value="2488095"/>
<attribute id="Folder" type="LSString" value="DnD 5.5e AIO Russian"/> <attribute id="Folder" type="LSString" value="DnD 5.5e AIO Russian"/>
<attribute id="LobbyLevelName" type="FixedString" value=""/> <attribute id="LobbyLevelName" type="FixedString" value=""/>
<attribute id="MD5" type="LSString" value="c0a8f3412870277331306e0719fc6f77"/> <attribute id="MD5" type="LSString" value="c0a8f3412870277331306e0719fc6f77"/>
@@ -32,7 +32,7 @@
<attribute id="PublishHandle" type="uint64" value="5965149"/> <attribute id="PublishHandle" type="uint64" value="5965149"/>
<attribute id="StartupLevelName" type="FixedString" value=""/> <attribute id="StartupLevelName" type="FixedString" value=""/>
<attribute id="UUID" type="FixedString" value="6401e84d-daf2-416d-adeb-99c03a2487a6"/> <attribute id="UUID" type="FixedString" value="6401e84d-daf2-416d-adeb-99c03a2487a6"/>
<attribute id="Version64" type="int64" value="281477124194306"/> <attribute id="Version64" type="int64" value="281492156579841"/>
<children> <children>
<node id="PublishVersion"> <node id="PublishVersion">
<attribute id="Version64" type="int64" value="281477124194304"/> <attribute id="Version64" type="int64" value="281477124194304"/>

View File

@@ -1,12 +1,11 @@
# DnD 5.5e All-in-One BEYOND Russian Localization # DnD 5.5e All-in-One BEYOND Russian Localization
Русский перевод мода **DnD 5.5e All-in-One BEYOND** для **Baldur's Gate 3**. Русская локализация [**DnD 5.5e All-in-One BEYOND**](https://github.com/Yoonmoonsik/dnd55e) для **Baldur's Gate 3**.
Оригинальный репозиторий: Публикация на [mod.io](https://mod.io/g/baldursgate3/m/dnd-55e-all-in-one-beyond-russian-localization).
[Yoonmoonsik/dnd55e](https://github.com/Yoonmoonsik/dnd55e)
## О моде ## О моде
**DnD 5.5e All-in-One BEYOND** переносит в Baldur's Gate 3 контент и механики, вдохновлённые Dungeons & Dragons 5.5e. Мод расширяет игру новыми и переработанными элементами, включая классы, предыстории, таланты, расы, заклинания и другие связанные системы. Русский перевод мода **DnD 5.5e All-in-One BEYOND**, который переносит в **Baldur's Gate 3** широкий пласт контента и правил **D&D 5.5e / PHB 2024**.
Этот проект предназначен для русской локализации оригинального мода и его текстового контента. Локализация поддерживается в темпе с апстримом, который сейчас развивается в сторону более полного охвата классов, рас, предысторий, фитов и заклинаний.

View File

@@ -459,7 +459,7 @@
"Locate animals or plants": "Поиск животных или растений", "Locate animals or plants": "Поиск животных или растений",
"Locate creature": "Поиск существа", "Locate creature": "Поиск существа",
"Locate object": "Поиск предмета", "Locate object": "Поиск предмета",
"Long Rest": "долгий отдых", "Long Rest": "Долгий отдых",
"Longbow": "Длинный лук", "Longbow": "Длинный лук",
"Longstrider": "Скороход", "Longstrider": "Скороход",
"Longsword": "Длинный меч", "Longsword": "Длинный меч",
@@ -695,7 +695,7 @@
"Shillelagh": "Шиллейла", "Shillelagh": "Шиллейла",
"Shocking Grasp": "Шоковое прикосновение", "Shocking Grasp": "Шоковое прикосновение",
"Shocking grasp": "Электрошок", "Shocking grasp": "Электрошок",
"Short Rest": "короткий отдых", "Short Rest": "Короткий отдых",
"Shortbow": "Короткий лук", "Shortbow": "Короткий лук",
"Shortsword": "Короткий меч", "Shortsword": "Короткий меч",
"Sickle": "Серп", "Sickle": "Серп",

View File

@@ -0,0 +1,210 @@
param(
[string]$RussianPath = "Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml",
[Parameter(Mandatory = $true)]
[string]$EditsPath
)
$ErrorActionPreference = "Stop"
function Read-XmlDocument {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$resolvedPath = [System.IO.Path]::GetFullPath($Path)
if (-not (Test-Path -LiteralPath $resolvedPath)) {
throw "XML file was not found: '$resolvedPath'."
}
[xml]$xml = Get-Content -LiteralPath $resolvedPath -Raw
if ($null -eq $xml.SelectSingleNode('/contentList')) {
throw "XML file does not contain '/contentList': '$resolvedPath'."
}
return @{
Path = $resolvedPath
Xml = $xml
}
}
function Get-ContentNodeMap {
param(
[Parameter(Mandatory = $true)]
[xml]$Xml
)
$map = @{}
foreach ($node in $Xml.SelectNodes('/contentList/content')) {
$contentUid = [string]$node.GetAttribute("contentuid")
if ([string]::IsNullOrWhiteSpace($contentUid)) {
continue
}
if ($map.ContainsKey($contentUid)) {
throw "Duplicate contentuid found in target XML: '$contentUid'."
}
$map[$contentUid] = $node
}
return $map
}
function Assert-UniqueEditContentUid {
param(
[AllowEmptyCollection()]
[Parameter(Mandatory = $true)]
[System.Collections.Generic.HashSet[string]]$Seen,
[Parameter(Mandatory = $true)]
[string]$ContentUid,
[Parameter(Mandatory = $true)]
[string]$Section
)
if (-not $Seen.Add($ContentUid)) {
throw "Edits file contains duplicate contentuid '$ContentUid' in '$Section'."
}
}
$russianDocument = Read-XmlDocument -Path $RussianPath
$temporaryRussianPath = "$($russianDocument.Path).tmp"
$validateScriptPath = Join-Path $PSScriptRoot "validate-translation-xml.ps1"
if (-not (Test-Path -LiteralPath $validateScriptPath)) {
throw "Validation script was not found: '$validateScriptPath'."
}
if (Test-Path -LiteralPath $temporaryRussianPath) {
Remove-Item -LiteralPath $temporaryRussianPath -Force
}
Copy-Item -LiteralPath $russianDocument.Path -Destination $temporaryRussianPath -Force
$russianDocument = Read-XmlDocument -Path $temporaryRussianPath
$resolvedEditsPath = [System.IO.Path]::GetFullPath($EditsPath)
if (-not (Test-Path -LiteralPath $resolvedEditsPath)) {
throw "Edits file was not found: '$resolvedEditsPath'."
}
$edits = Get-Content -LiteralPath $resolvedEditsPath -Raw | ConvertFrom-Json
if ($null -eq $edits) {
throw "Edits file is empty or invalid JSON: '$resolvedEditsPath'."
}
$contentListNode = $russianDocument.Xml.SelectSingleNode('/contentList')
if ($null -eq $contentListNode) {
throw "Target russian.xml does not contain '/contentList': '$($russianDocument.Path)'."
}
$nodeMap = Get-ContentNodeMap -Xml $russianDocument.Xml
$updatedEntries = New-Object System.Collections.Generic.List[string]
$addedEntries = New-Object System.Collections.Generic.List[string]
$seenEditContentUids = [System.Collections.Generic.HashSet[string]]::new()
$updates = @()
if ($edits.PSObject.Properties.Name -contains "updates" -and $null -ne $edits.updates) {
$updates = @($edits.updates)
}
foreach ($edit in $updates) {
$contentUid = [string]$edit.contentuid
if ([string]::IsNullOrWhiteSpace($contentUid)) {
throw "Each update entry must contain non-empty 'contentuid'."
}
Assert-UniqueEditContentUid -Seen $seenEditContentUids -ContentUid $contentUid -Section "updates"
if (-not $nodeMap.ContainsKey($contentUid)) {
throw "Target russian.xml does not contain contentuid '$contentUid' for update."
}
$node = $nodeMap[$contentUid]
if ($edit.PSObject.Properties.Name -contains "version" -and -not [string]::IsNullOrWhiteSpace([string]$edit.version)) {
$node.SetAttribute("version", [string]$edit.version)
}
if ($edit.PSObject.Properties.Name -contains "text") {
if ([string]::IsNullOrWhiteSpace([string]$edit.text)) {
throw "Update entry '$contentUid' must contain non-empty 'text' when provided."
}
$node.InnerText = [string]$edit.text
}
$updatedEntries.Add($contentUid) | Out-Null
}
$adds = @()
if ($edits.PSObject.Properties.Name -contains "adds" -and $null -ne $edits.adds) {
$adds = @($edits.adds)
}
if (($updates.Count -eq 0) -and ($adds.Count -eq 0)) {
if (Test-Path -LiteralPath $temporaryRussianPath) {
Remove-Item -LiteralPath $temporaryRussianPath -Force
}
Write-Host "[apply-translation-edits.ps1] No edits requested. Original russian.xml left unchanged."
return
}
foreach ($edit in $adds) {
$contentUid = [string]$edit.contentuid
$version = [string]$edit.version
$text = [string]$edit.text
if ([string]::IsNullOrWhiteSpace($contentUid)) {
throw "Each add entry must contain non-empty 'contentuid'."
}
Assert-UniqueEditContentUid -Seen $seenEditContentUids -ContentUid $contentUid -Section "adds"
if ($nodeMap.ContainsKey($contentUid)) {
throw "Target russian.xml already contains contentuid '$contentUid'; use 'updates' instead of 'adds'."
}
if ([string]::IsNullOrWhiteSpace($version)) {
throw "Add entry '$contentUid' must contain non-empty 'version'."
}
if ([string]::IsNullOrWhiteSpace($text)) {
throw "Add entry '$contentUid' must contain non-empty 'text'."
}
$newNode = $russianDocument.Xml.CreateElement("content")
$newNode.SetAttribute("contentuid", $contentUid)
$newNode.SetAttribute("version", $version)
$newNode.InnerText = $text
[void]$contentListNode.AppendChild($newNode)
$nodeMap[$contentUid] = $newNode
$addedEntries.Add($contentUid) | Out-Null
}
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($true)
$settings.Indent = $true
$settings.IndentChars = " "
$settings.NewLineChars = "`n"
$settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace
$writer = [System.Xml.XmlWriter]::Create($russianDocument.Path, $settings)
try {
$russianDocument.Xml.WriteTo($writer)
} finally {
$writer.Dispose()
}
try {
& $validateScriptPath -XmlPath $temporaryRussianPath
Move-Item -LiteralPath $temporaryRussianPath -Destination $RussianPath -Force
} finally {
if (Test-Path -LiteralPath $temporaryRussianPath) {
Remove-Item -LiteralPath $temporaryRussianPath -Force
}
}
Write-Host "[apply-translation-edits.ps1] Updated entries: $($updatedEntries.Count); Added entries: $($addedEntries.Count)."
if ($updatedEntries.Count -gt 0) {
Write-Host "[apply-translation-edits.ps1] Updated contentuid: $($updatedEntries -join ', ')"
}
if ($addedEntries.Count -gt 0) {
Write-Host "[apply-translation-edits.ps1] Added contentuid: $($addedEntries -join ', ')"
}

View File

@@ -7,7 +7,7 @@ param(
[string]$ArchiveBaseName = "DnD 5.5e AIO Russian", [string]$ArchiveBaseName = "DnD 5.5e AIO Russian",
[string]$ModName = "DnD 5.5e All-in-One BEYOND Russian Localization", [string]$ModName = "DnD 5.5e All-in-One BEYOND Russian Localization",
[string]$ModUuid = "6401e84d-daf2-416d-adeb-99c03a2487a6", [string]$ModUuid = "6401e84d-daf2-416d-adeb-99c03a2487a6",
[string]$ModAuthor = "MikhailRaw", [string]$ModAuthor = "Underslumber Team",
[string]$ModDescription = "Русская локализация мода, который добавляет и обновляет контент в соответствии с правилами DnD 5.5e и другими источниками, включая предыстории, классы, таланты, расы, заклинания и многое другое. Это отдельный мод локализации и он требует установленный оригинальный мод.", [string]$ModDescription = "Русская локализация мода, который добавляет и обновляет контент в соответствии с правилами DnD 5.5e и другими источниками, включая предыстории, классы, таланты, расы, заклинания и многое другое. Это отдельный мод локализации и он требует установленный оригинальный мод.",
[string]$ModVersion64 = "36028797018963968", [string]$ModVersion64 = "36028797018963968",
[string]$ModGroup = "6401e84d-daf2-416d-adeb-99c03a2487a6", [string]$ModGroup = "6401e84d-daf2-416d-adeb-99c03a2487a6",
@@ -20,7 +20,8 @@ $ErrorActionPreference = "Stop"
function Convert-VersionTagToVersion64 { function Convert-VersionTagToVersion64 {
param( param(
[string]$Tag, [string]$Tag,
[string]$FallbackVersion64 [string]$FallbackVersion64,
[string]$RepoPath
) )
if (-not $Tag) { if (-not $Tag) {
@@ -32,16 +33,31 @@ function Convert-VersionTagToVersion64 {
$normalized = $normalized.Substring(1) $normalized = $normalized.Substring(1)
} }
if ($normalized -notmatch '^\d+(\.\d+){0,3}$') { if ($normalized -notmatch '^(?<base>\d+\.\d+\.\d+)(?:-(?<suffix>[0-9A-Za-z][0-9A-Za-z.-]*))?$') {
return [int64]$FallbackVersion64 throw "Version tag '$Tag' is invalid. Expected format: vX.Y.Z or vX.Y.Z-suffix"
} }
$parts = $normalized.Split(".") $baseVersion = $Matches.base
$suffix = $Matches.suffix
$parts = $baseVersion.Split(".")
$numbers = @(0, 0, 0, 0) $numbers = @(0, 0, 0, 0)
for ($i = 0; $i -lt $parts.Length; $i++) { for ($i = 0; $i -lt $parts.Length; $i++) {
$numbers[$i] = [int]$parts[$i] $numbers[$i] = [int]$parts[$i]
} }
if ($suffix) {
$resolvedRepoPath = [System.IO.Path]::GetFullPath($RepoPath)
$matchingTags = @()
try {
$matchingTags = @(git -C $resolvedRepoPath tag --list "v$baseVersion-*" 2>$null | Where-Object { $_ -and $_ -ne $Tag })
} catch {
$matchingTags = @()
}
$numbers[3] = $matchingTags.Count + 1
}
return ([int64]$numbers[0] -shl 55) -bor ([int64]$numbers[1] -shl 47) -bor ([int64]$numbers[2] -shl 31) -bor [int64]$numbers[3] return ([int64]$numbers[0] -shl 55) -bor ([int64]$numbers[1] -shl 47) -bor ([int64]$numbers[2] -shl 31) -bor [int64]$numbers[3]
} }
@@ -59,7 +75,7 @@ if ($VersionTag) {
} }
$zipPath = Join-Path $buildPath "$archiveName.zip" $zipPath = Join-Path $buildPath "$archiveName.zip"
$infoJsonPath = Join-Path $buildPath "info.json" $infoJsonPath = Join-Path $buildPath "info.json"
$resolvedVersion64 = Convert-VersionTagToVersion64 -Tag $VersionTag -FallbackVersion64 $ModVersion64 $resolvedVersion64 = Convert-VersionTagToVersion64 -Tag $VersionTag -FallbackVersion64 $ModVersion64 -RepoPath $workspacePath
if (-not (Test-Path -LiteralPath $DivinePath)) { if (-not (Test-Path -LiteralPath $DivinePath)) {
$resolvedCommand = Get-Command $DivinePath -ErrorAction SilentlyContinue $resolvedCommand = Get-Command $DivinePath -ErrorAction SilentlyContinue
@@ -101,10 +117,10 @@ if (-not (Test-Path -LiteralPath $stagedMetaPath)) {
throw "Staged meta.lsx was not found: '$stagedMetaPath'." throw "Staged meta.lsx was not found: '$stagedMetaPath'."
} }
$stagedMetaContent = Get-Content -LiteralPath $stagedMetaPath -Raw $utf8Encoding = [System.Text.UTF8Encoding]::new($false)
$stagedMetaContent = [System.IO.File]::ReadAllText($stagedMetaPath, $utf8Encoding)
$stagedMetaContent = $stagedMetaContent -replace '(<attribute id="Version64" type="int64" value=")\d+("/>)', "`${1}$resolvedVersion64`${2}" $stagedMetaContent = $stagedMetaContent -replace '(<attribute id="Version64" type="int64" value=")\d+("/>)', "`${1}$resolvedVersion64`${2}"
$utf8Bom = New-Object System.Text.UTF8Encoding($true) [System.IO.File]::WriteAllText($stagedMetaPath, $stagedMetaContent, $utf8Encoding)
[System.IO.File]::WriteAllText($stagedMetaPath, $stagedMetaContent, $utf8Bom)
Write-Host "[build.ps1] Staged source tree:" Write-Host "[build.ps1] Staged source tree:"
Get-ChildItem -Recurse $stagingPath | Select-Object FullName, Length | Format-Table -AutoSize Get-ChildItem -Recurse $stagingPath | Select-Object FullName, Length | Format-Table -AutoSize

View File

@@ -0,0 +1,209 @@
param(
[string]$EnglishPath = ".cache/upstream/english.xml",
[string]$RussianPath = "Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml",
[string]$OutputDir = "build/translation-diff"
)
$ErrorActionPreference = "Stop"
function Get-LocalizationEntries {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$resolvedPath = [System.IO.Path]::GetFullPath($Path)
if (-not (Test-Path -LiteralPath $resolvedPath)) {
throw "Localization XML was not found: '$resolvedPath'."
}
[xml]$xml = Get-Content -LiteralPath $resolvedPath -Raw
$nodes = $xml.SelectNodes('/contentList/content')
if ($null -eq $nodes) {
throw "Localization XML does not contain '/contentList/content' nodes: '$resolvedPath'."
}
$entries = @{}
foreach ($node in $nodes) {
$contentUid = [string]$node.GetAttribute("contentuid")
if ([string]::IsNullOrWhiteSpace($contentUid)) {
throw "Localization XML contains a content node without 'contentuid': '$resolvedPath'."
}
if ($entries.ContainsKey($contentUid)) {
throw "Localization XML contains duplicate contentuid '$contentUid': '$resolvedPath'."
}
$version = [string]$node.GetAttribute("version")
if ([string]::IsNullOrWhiteSpace($version)) {
throw "Localization XML contains contentuid '$contentUid' with empty 'version': '$resolvedPath'."
}
$entries[$contentUid] = [ordered]@{
contentuid = $contentUid
version = $version
text = [string]$node.InnerText
}
}
return $entries
}
$resolvedOutputDir = [System.IO.Path]::GetFullPath($OutputDir)
New-Item -ItemType Directory -Path $resolvedOutputDir -Force | Out-Null
$englishEntries = Get-LocalizationEntries -Path $EnglishPath
$russianEntries = Get-LocalizationEntries -Path $RussianPath
$missingInRussian = New-Object System.Collections.Generic.List[object]
$versionMismatch = New-Object System.Collections.Generic.List[object]
$staleOnlyInRussian = New-Object System.Collections.Generic.List[object]
foreach ($contentUid in ($englishEntries.Keys | Sort-Object)) {
$englishEntry = $englishEntries[$contentUid]
if (-not $russianEntries.ContainsKey($contentUid)) {
$missingInRussian.Add([pscustomobject]@{
contentuid = $contentUid
englishVersion = $englishEntry.version
englishText = $englishEntry.text
}) | Out-Null
continue
}
$russianEntry = $russianEntries[$contentUid]
if ($englishEntry.version -ne $russianEntry.version) {
$versionMismatch.Add([pscustomobject]@{
contentuid = $contentUid
englishVersion = $englishEntry.version
russianVersion = $russianEntry.version
englishText = $englishEntry.text
russianText = $russianEntry.text
}) | Out-Null
}
}
foreach ($contentUid in ($russianEntries.Keys | Sort-Object)) {
if (-not $englishEntries.ContainsKey($contentUid)) {
$russianEntry = $russianEntries[$contentUid]
$staleOnlyInRussian.Add([pscustomobject]@{
contentuid = $contentUid
russianVersion = $russianEntry.version
russianText = $russianEntry.text
}) | Out-Null
}
}
$summary = [ordered]@{
generatedAt = (Get-Date).ToString("o")
englishPath = [System.IO.Path]::GetFullPath($EnglishPath)
russianPath = [System.IO.Path]::GetFullPath($RussianPath)
englishCount = $englishEntries.Count
russianCount = $russianEntries.Count
missingInRussianCount = $missingInRussian.Count
versionMismatchCount = $versionMismatch.Count
staleOnlyInRussianCount = $staleOnlyInRussian.Count
missingInRussian = $missingInRussian
versionMismatch = $versionMismatch
staleOnlyInRussian = $staleOnlyInRussian
}
$summaryJsonPath = Join-Path $resolvedOutputDir "summary.json"
$summaryMdPath = Join-Path $resolvedOutputDir "summary.md"
$candidatesJsonPath = Join-Path $resolvedOutputDir "candidates.json"
$summary | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $summaryJsonPath -Encoding utf8
$candidates = [ordered]@{
generatedAt = (Get-Date).ToString("o")
source = [ordered]@{
englishPath = [System.IO.Path]::GetFullPath($EnglishPath)
russianPath = [System.IO.Path]::GetFullPath($RussianPath)
}
updates = @(
$versionMismatch | ForEach-Object {
[ordered]@{
contentuid = $_.contentuid
version = $_.englishVersion
text = $_.russianText
englishText = $_.englishText
russianVersion = $_.russianVersion
}
}
)
adds = @(
$missingInRussian | ForEach-Object {
[ordered]@{
contentuid = $_.contentuid
version = $_.englishVersion
text = ""
englishText = $_.englishText
}
}
)
}
$candidates | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $candidatesJsonPath -Encoding utf8
$isUpToDate = ($missingInRussian.Count -eq 0) -and ($versionMismatch.Count -eq 0) -and ($staleOnlyInRussian.Count -eq 0)
$mdLines = @(
"# Translation diff summary",
"",
"- Generated: $($summary.generatedAt)",
"- English entries: $($summary.englishCount)",
"- Russian entries: $($summary.russianCount)",
"- Missing in Russian: $($summary.missingInRussianCount)",
"- Version mismatches: $($summary.versionMismatchCount)",
"- Stale only in Russian: $($summary.staleOnlyInRussianCount)",
""
)
if ($isUpToDate) {
$mdLines += "Перевод уже актуален, дополнительные действия не требуются."
} else {
$mdLines += ""
$mdLines += "## Agent workflow"
$mdLines += "1. Refresh upstream cache: ``scripts/get-upstream-english.ps1``"
$mdLines += "2. Refresh diff reports: ``scripts/compare-translation.ps1``"
$mdLines += "3. Fill translated texts in ``build/translation-diff/candidates.json``"
$mdLines += "4. Apply only prepared edits: ``scripts/apply-translation-edits.ps1 -EditsPath build/translation-diff/candidates.json``"
}
$mdLines += ""
$mdLines += "## Missing in Russian"
if ($missingInRussian.Count -eq 0) {
$mdLines += "- none"
} else {
$mdLines += ($missingInRussian | Select-Object -First 50 | ForEach-Object {
"- ``$($_.contentuid)`` v$($_.englishVersion): $($_.englishText)"
})
}
$mdLines += ""
$mdLines += "## Version mismatches"
if ($versionMismatch.Count -eq 0) {
$mdLines += "- none"
} else {
$mdLines += ($versionMismatch | Select-Object -First 50 | ForEach-Object {
"- ``$($_.contentuid)`` en=v$($_.englishVersion), ru=v$($_.russianVersion)"
})
}
$mdLines += ""
$mdLines += "## Stale only in Russian"
if ($staleOnlyInRussian.Count -eq 0) {
$mdLines += "- none"
} else {
$mdLines += ($staleOnlyInRussian | Select-Object -First 50 | ForEach-Object {
"- ``$($_.contentuid)`` v$($_.russianVersion): $($_.russianText)"
})
}
Set-Content -LiteralPath $summaryMdPath -Value $mdLines -Encoding utf8
Write-Host "[compare-translation.ps1] Summary written to '$summaryJsonPath' and '$summaryMdPath'."
Write-Host "[compare-translation.ps1] Editable candidate file written to '$candidatesJsonPath'."
Write-Host "[compare-translation.ps1] Missing=$($missingInRussian.Count); VersionMismatch=$($versionMismatch.Count); StaleOnlyInRussian=$($staleOnlyInRussian.Count)."
if ($isUpToDate) {
Write-Host "[compare-translation.ps1] Перевод уже актуален, дополнительные действия не требуются."
}

View File

@@ -0,0 +1,44 @@
param(
[string]$UpstreamEnglishUrl = "https://raw.githubusercontent.com/Yoonmoonsik/dnd55e/main/Mods/DnD2024_897914ef-5c96-053c-44af-0be823f895fe/Localization/English/english.xml",
[string]$OutputPath = ".cache/upstream/english.xml",
[switch]$Force
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($UpstreamEnglishUrl)) {
throw "UpstreamEnglishUrl must not be empty."
}
$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$outputDirectory = Split-Path -Parent $resolvedOutputPath
if (-not (Test-Path -LiteralPath $outputDirectory)) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
$requestParameters = @{
Uri = $UpstreamEnglishUrl
OutFile = $resolvedOutputPath
UseBasicParsing = $true
TimeoutSec = 120
}
if ($Force) {
$requestParameters["Headers"] = @{
"Cache-Control" = "no-cache"
}
}
Invoke-WebRequest @requestParameters
[xml]$englishXml = Get-Content -LiteralPath $resolvedOutputPath -Raw
$rootNode = $englishXml.SelectSingleNode('/contentList')
if ($null -eq $rootNode) {
throw "Downloaded file is not a valid BG3 localization XML: '$resolvedOutputPath'."
}
$contentCount = $englishXml.SelectNodes('/contentList/content').Count
$fileInfo = Get-Item -LiteralPath $resolvedOutputPath
Write-Host "[get-upstream-english.ps1] Saved upstream english.xml to '$resolvedOutputPath'."
Write-Host "[get-upstream-english.ps1] Entries: $contentCount; Size: $($fileInfo.Length) bytes; Updated: $($fileInfo.LastWriteTime.ToString("o"))."

View File

@@ -0,0 +1,113 @@
[CmdletBinding()]
param(
[Parameter()]
[string]$BotToken,
[Parameter()]
[string]$ChatId,
[Parameter()]
[string]$ThreadId,
[Parameter(Mandatory = $true)]
[string]$Text,
[switch]$DisableNotification
)
$ErrorActionPreference = "Stop"
function Import-DotEnvFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
return
}
foreach ($line in [System.IO.File]::ReadAllLines($Path)) {
$trimmedLine = $line.Trim()
if (-not $trimmedLine -or $trimmedLine.StartsWith("#")) {
continue
}
$separatorIndex = $trimmedLine.IndexOf("=")
if ($separatorIndex -lt 1) {
continue
}
$name = $trimmedLine.Substring(0, $separatorIndex).Trim()
$value = $trimmedLine.Substring($separatorIndex + 1).Trim()
if (
($value.StartsWith('"') -and $value.EndsWith('"')) -or
($value.StartsWith("'") -and $value.EndsWith("'"))
) {
$value = $value.Substring(1, $value.Length - 2)
}
[System.Environment]::SetEnvironmentVariable($name, $value, "Process")
}
}
function Resolve-Setting {
param(
[string]$ExplicitValue,
[Parameter(Mandatory = $true)]
[string]$EnvName
)
if (-not [string]::IsNullOrWhiteSpace($ExplicitValue)) {
return $ExplicitValue
}
$envValue = [System.Environment]::GetEnvironmentVariable($EnvName, "Process")
if (-not [string]::IsNullOrWhiteSpace($envValue)) {
return $envValue
}
return $null
}
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$localEnvPath = Join-Path $repoRoot ".env.local"
Import-DotEnvFile -Path $localEnvPath
$BotToken = Resolve-Setting -ExplicitValue $BotToken -EnvName "BOT_TOKEN"
$ChatId = Resolve-Setting -ExplicitValue $ChatId -EnvName "TG_CHAT_ID"
$ThreadId = Resolve-Setting -ExplicitValue $ThreadId -EnvName "TG_THREAD_ID"
if ([string]::IsNullOrWhiteSpace($BotToken)) {
throw "Telegram bot token is required. Pass -BotToken or set BOT_TOKEN in .env.local."
}
if ([string]::IsNullOrWhiteSpace($ChatId)) {
throw "Telegram chat id is required. Pass -ChatId or set TG_CHAT_ID in .env.local."
}
if ([string]::IsNullOrWhiteSpace($ThreadId)) {
throw "Telegram thread id is required. Pass -ThreadId or set TG_THREAD_ID in .env.local."
}
$normalizedText = $Text.
Replace("``r``n", "`r`n").
Replace("``n", "`n").
Replace("%0A", "`n")
$body = @{
chat_id = $ChatId
message_thread_id = $ThreadId
parse_mode = "HTML"
text = $normalizedText
}
if ($DisableNotification) {
$body.disable_notification = "true"
}
Invoke-RestMethod `
-Method Post `
-Uri ("https://api.telegram.org/bot{0}/sendMessage" -f $BotToken) `
-Body $body | Out-Null

View File

@@ -1,14 +1,16 @@
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$VersionTag, [string]$VersionTag,
[string]$MetaPath = "Mods/DnD 5.5e AIO Russian/meta.lsx" [string]$MetaPath = "Mods/DnD 5.5e AIO Russian/meta.lsx",
[string]$RepositoryPath = "."
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
function Convert-VersionTagToVersion64 { function Get-ReleaseVersionParts {
param( param(
[string]$Tag [string]$Tag,
[string]$RepoPath
) )
$normalized = $Tag $normalized = $Tag
@@ -16,17 +18,36 @@ function Convert-VersionTagToVersion64 {
$normalized = $normalized.Substring(1) $normalized = $normalized.Substring(1)
} }
if ($normalized -notmatch '^\d+(\.\d+){0,3}$') { if ($normalized -notmatch '^(?<base>\d+\.\d+\.\d+)(?:-(?<suffix>[0-9A-Za-z][0-9A-Za-z.-]*))?$') {
throw "Version tag '$Tag' is invalid. Expected format: vX.Y.Z or X.Y.Z" throw "Version tag '$Tag' is invalid. Expected format: vX.Y.Z or vX.Y.Z-suffix"
} }
$parts = $normalized.Split(".") $baseVersion = $Matches.base
$suffix = $Matches.suffix
$parts = $baseVersion.Split(".")
$numbers = @(0, 0, 0, 0) $numbers = @(0, 0, 0, 0)
for ($i = 0; $i -lt $parts.Length; $i++) { for ($i = 0; $i -lt $parts.Length; $i++) {
$numbers[$i] = [int]$parts[$i] $numbers[$i] = [int]$parts[$i]
} }
return ([int64]$numbers[0] -shl 55) -bor ([int64]$numbers[1] -shl 47) -bor ([int64]$numbers[2] -shl 31) -bor [int64]$numbers[3] if ($suffix) {
$resolvedRepoPath = [System.IO.Path]::GetFullPath($RepoPath)
$matchingTags = @()
try {
$matchingTags = @(git -C $resolvedRepoPath tag --list "v$baseVersion-*" 2>$null | Where-Object { $_ -and $_ -ne $Tag })
} catch {
$matchingTags = @()
}
$numbers[3] = $matchingTags.Count + 1
}
return [pscustomobject]@{
BaseVersion = $baseVersion
Suffix = $suffix
Version64 = ([int64]$numbers[0] -shl 55) -bor ([int64]$numbers[1] -shl 47) -bor ([int64]$numbers[2] -shl 31) -bor [int64]$numbers[3]
}
} }
$resolvedMetaPath = [System.IO.Path]::GetFullPath($MetaPath) $resolvedMetaPath = [System.IO.Path]::GetFullPath($MetaPath)
@@ -34,8 +55,10 @@ if (-not (Test-Path -LiteralPath $resolvedMetaPath)) {
throw "meta.lsx was not found: '$resolvedMetaPath'." throw "meta.lsx was not found: '$resolvedMetaPath'."
} }
$resolvedVersion64 = Convert-VersionTagToVersion64 -Tag $VersionTag $releaseVersion = Get-ReleaseVersionParts -Tag $VersionTag -RepoPath $RepositoryPath
$metaContent = Get-Content -LiteralPath $resolvedMetaPath -Raw $resolvedVersion64 = $releaseVersion.Version64
$utf8Encoding = [System.Text.UTF8Encoding]::new($false)
$metaContent = [System.IO.File]::ReadAllText($resolvedMetaPath, $utf8Encoding)
[xml]$metaXml = $metaContent [xml]$metaXml = $metaContent
# Explicitly target ModuleInfo/Version64 via XML path to avoid touching Dependencies/PublishVersion. # Explicitly target ModuleInfo/Version64 via XML path to avoid touching Dependencies/PublishVersion.
@@ -57,7 +80,6 @@ $updatedMeta = [regex]::Replace(
1 1
) )
$utf8Bom = New-Object System.Text.UTF8Encoding($true) [System.IO.File]::WriteAllText($resolvedMetaPath, $updatedMeta, $utf8Encoding)
[System.IO.File]::WriteAllText($resolvedMetaPath, $updatedMeta, $utf8Bom)
Write-Host "[set-version.ps1] Updated '$resolvedMetaPath' to Version64=$resolvedVersion64 (from tag '$VersionTag')." Write-Host "[set-version.ps1] Updated '$resolvedMetaPath' to Version64=$resolvedVersion64 (from tag '$VersionTag', base '$($releaseVersion.BaseVersion)')."

View File

@@ -0,0 +1,91 @@
param(
[string]$ParentMetaUrl = "https://raw.githubusercontent.com/Yoonmoonsik/dnd55e/main/Mods/DnD2024_897914ef-5c96-053c-44af-0be823f895fe/meta.lsx",
[string]$TargetMetaPath = "Mods/DnD 5.5e AIO Russian/meta.lsx"
)
$ErrorActionPreference = "Stop"
$resolvedTargetMetaPath = [System.IO.Path]::GetFullPath($TargetMetaPath)
if (-not (Test-Path -LiteralPath $resolvedTargetMetaPath)) {
throw "Target meta.lsx was not found: '$resolvedTargetMetaPath'."
}
if ([string]::IsNullOrWhiteSpace($ParentMetaUrl)) {
throw "ParentMetaUrl must not be empty."
}
try {
$parentResponse = Invoke-WebRequest -Uri $ParentMetaUrl -UseBasicParsing -TimeoutSec 60
} catch {
throw "Failed to download parent meta.lsx from '$ParentMetaUrl': $($_.Exception.Message)"
}
$parentRaw = $parentResponse.Content
$targetRaw = Get-Content -LiteralPath $resolvedTargetMetaPath -Raw
[xml]$parentXml = $parentRaw
[xml]$targetXml = $targetRaw
$parentModuleInfo = $parentXml.SelectSingleNode('/save/region/node/children/node[@id="ModuleInfo"]')
if ($null -eq $parentModuleInfo) {
throw "ModuleInfo node was not found in parent meta downloaded from '$ParentMetaUrl'."
}
$requiredFields = @("Folder", "MD5", "Name", "PublishHandle", "UUID", "Version64")
$sourceValues = @{}
foreach ($field in $requiredFields) {
$node = $parentModuleInfo.SelectSingleNode("attribute[@id='$field']")
if ($null -eq $node) {
throw "Required parent ModuleInfo attribute '$field' is missing in meta downloaded from '$ParentMetaUrl'."
}
$value = $node.GetAttribute("value")
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Required parent ModuleInfo attribute '$field' has empty value in meta downloaded from '$ParentMetaUrl'."
}
$sourceValues[$field] = $value
}
$targetDependencyNode = $targetXml.SelectSingleNode('/save/region/node/children/node[@id="Dependencies"]/children/node[@id="ModuleShortDesc"]')
if ($null -eq $targetDependencyNode) {
throw "Dependencies/ModuleShortDesc node was not found in target meta: '$resolvedTargetMetaPath'."
}
$changedFields = @()
foreach ($field in $requiredFields) {
$targetAttr = $targetDependencyNode.SelectSingleNode("attribute[@id='$field']")
if ($null -eq $targetAttr) {
throw "Target Dependencies/ModuleShortDesc attribute '$field' is missing in '$resolvedTargetMetaPath'."
}
$currentValue = $targetAttr.GetAttribute("value")
$newValue = [string]$sourceValues[$field]
if ($currentValue -ne $newValue) {
$targetAttr.SetAttribute("value", $newValue)
$changedFields += $field
}
}
if ($changedFields.Count -eq 0) {
Write-Host "[sync-parent-meta.ps1] No changes needed. Target dependency data is already up to date."
} else {
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = $utf8Bom
$settings.Indent = $true
$settings.IndentChars = " "
$settings.NewLineChars = "`n"
$settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace
$writer = [System.Xml.XmlWriter]::Create($resolvedTargetMetaPath, $settings)
try {
$targetXml.WriteTo($writer)
} finally {
$writer.Dispose()
}
Write-Host ("[sync-parent-meta.ps1] Updated fields: " + ($changedFields -join ", "))
}

View File

@@ -0,0 +1,71 @@
param(
[string]$RussianPath = "Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml",
[string]$EnglishPath = ".cache/upstream/english.xml",
[string]$OutputDir = "build/translation-diff",
[string]$EditsPath = ""
)
$ErrorActionPreference = "Stop"
$getUpstreamScriptPath = Join-Path $PSScriptRoot "get-upstream-english.ps1"
$compareScriptPath = Join-Path $PSScriptRoot "compare-translation.ps1"
$applyScriptPath = Join-Path $PSScriptRoot "apply-translation-edits.ps1"
foreach ($scriptPath in @($getUpstreamScriptPath, $compareScriptPath, $applyScriptPath)) {
if (-not (Test-Path -LiteralPath $scriptPath)) {
throw "Required script was not found: '$scriptPath'."
}
}
$resolvedOutputDir = [System.IO.Path]::GetFullPath($OutputDir)
$resolvedProvidedEditsPath = ""
if (-not [string]::IsNullOrWhiteSpace($EditsPath)) {
$resolvedProvidedEditsPath = [System.IO.Path]::GetFullPath($EditsPath)
}
$workingDiffDir = Join-Path $env:TEMP ("bg3-translation-update-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $workingDiffDir -Force | Out-Null
try {
& $getUpstreamScriptPath -OutputPath $EnglishPath -Force
& $compareScriptPath -EnglishPath $EnglishPath -RussianPath $RussianPath -OutputDir $workingDiffDir
New-Item -ItemType Directory -Path $resolvedOutputDir -Force | Out-Null
Get-ChildItem -LiteralPath $workingDiffDir | ForEach-Object {
$destinationPath = Join-Path $resolvedOutputDir $_.Name
if ($resolvedProvidedEditsPath -and ([System.IO.Path]::GetFullPath($destinationPath) -eq $resolvedProvidedEditsPath) -and (Test-Path -LiteralPath $resolvedProvidedEditsPath)) {
return
}
Copy-Item -LiteralPath $_.FullName -Destination $resolvedOutputDir -Recurse -Force
}
$summaryJsonPath = Join-Path $workingDiffDir "summary.json"
if (-not (Test-Path -LiteralPath $summaryJsonPath)) {
throw "Translation summary was not found: '$summaryJsonPath'."
}
$summary = Get-Content -LiteralPath $summaryJsonPath -Raw | ConvertFrom-Json -Depth 10
$hasDiff = ($summary.missingInRussianCount -gt 0) -or ($summary.versionMismatchCount -gt 0) -or ($summary.staleOnlyInRussianCount -gt 0)
if (-not $hasDiff) {
Write-Host "[update-translation.ps1] Перевод уже актуален, дополнительные действия не требуются."
return
}
if ([string]::IsNullOrWhiteSpace($EditsPath)) {
Write-Host "[update-translation.ps1] Найдены изменения перевода. Подготовьте правки в '$([System.IO.Path]::GetFullPath((Join-Path $resolvedOutputDir "candidates.json")))' и затем запустите повторно с '-EditsPath'."
return
}
$effectiveEditsPath = [System.IO.Path]::GetFullPath($EditsPath)
if (-not (Test-Path -LiteralPath $effectiveEditsPath)) {
throw "Prepared edits file was not found: '$effectiveEditsPath'."
}
& $applyScriptPath -RussianPath $RussianPath -EditsPath $effectiveEditsPath
Write-Host "[update-translation.ps1] Обновление перевода завершено. Результат записан в '$([System.IO.Path]::GetFullPath($RussianPath))'."
} finally {
if (Test-Path -LiteralPath $workingDiffDir) {
Remove-Item -LiteralPath $workingDiffDir -Recurse -Force
}
}

View File

@@ -0,0 +1,41 @@
param(
[Parameter(Mandatory = $true)]
[string]$XmlPath
)
$ErrorActionPreference = "Stop"
$resolvedXmlPath = [System.IO.Path]::GetFullPath($XmlPath)
if (-not (Test-Path -LiteralPath $resolvedXmlPath)) {
throw "XML file was not found: '$resolvedXmlPath'."
}
[xml]$xml = Get-Content -LiteralPath $resolvedXmlPath -Raw
$contentListNode = $xml.SelectSingleNode('/contentList')
if ($null -eq $contentListNode) {
throw "XML validation failed: missing '/contentList' in '$resolvedXmlPath'."
}
$contentNodes = $xml.SelectNodes('/contentList/content')
if ($null -eq $contentNodes -or $contentNodes.Count -lt 1) {
throw "XML validation failed: no '/contentList/content' entries found in '$resolvedXmlPath'."
}
$seen = [System.Collections.Generic.HashSet[string]]::new()
foreach ($node in $contentNodes) {
$contentUid = [string]$node.GetAttribute("contentuid")
if ([string]::IsNullOrWhiteSpace($contentUid)) {
throw "XML validation failed: found content node without 'contentuid' in '$resolvedXmlPath'."
}
if (-not $seen.Add($contentUid)) {
throw "XML validation failed: duplicate contentuid '$contentUid' in '$resolvedXmlPath'."
}
$version = [string]$node.GetAttribute("version")
if ([string]::IsNullOrWhiteSpace($version)) {
throw "XML validation failed: contentuid '$contentUid' has empty 'version' in '$resolvedXmlPath'."
}
}
Write-Host "[validate-translation-xml.ps1] XML is valid: '$resolvedXmlPath'. Entries=$($contentNodes.Count)."