diff --git a/MD/ChronicIssueList/2026-06-29-movement-displacement-sight-refresh.md b/MD/ChronicIssueList/2026-06-29-movement-displacement-sight-refresh.md new file mode 100644 index 000000000..673c683a6 --- /dev/null +++ b/MD/ChronicIssueList/2026-06-29-movement-displacement-sight-refresh.md @@ -0,0 +1,56 @@ +# TH1-CI-2026-06-29-001 Movement Displacement Sight Refresh + +- Status: Fixed in code; guardrail added; Unity validation pending +- First recorded date: 2026-06-29 +- Severity: High + +## Raw Symptom + +Suika Lv4 falling splash attacks a unit exactly 3 grids away. When the attack does not kill the target, Suika lands on an adjacent side grid, but the fog/sight around that final landing position does not open correctly. + +Affected entrypoints: + +- `SuikaFallingSplashSkill.AfterActiveAttackOther` +- `HakureiNorwayHeroSkillUtil.TryExecuteSuikaFlyAfterAttack` +- `MapSightData.UpdateSightByPath` +- `FragmentSuikaFallingSplash.RefreshLandingSight` + +## Why This Is Recurring + +TH1 has several movement-like paths that do not go through the standard `UnitMoveAction` presentation sequence: forced landing, push, throw, teleport, swap, and hero-specific jumps. Each path must update data, unit visuals, grid/city visuals, highlights, and sight. + +The shared `UpdateSightByPath` helper was named like a movement sight refresh but used `GetAroundGridIdList(range, grid)` with the default `remainCenter: false`. That means the unit's final standing grid was not guaranteed to enter `SightGidSet`. Standard movement often hides this because the path or destination was already visible, but displacement to a side landing grid can expose the missing center grid. + +Suika falling splash also maintains a separate list of newly opened grids for its delayed landing fragment. That list used the same default center-excluding radius query, so the fragment could also skip manually refreshing the final landing grid. + +## Root Cause + +The movement sight contract was ambiguous. "Update sight by path / unit standing at pos" should reveal the standing grid plus surrounding sight radius, but the implementation delegated to a radius helper whose default excludes the center grid. + +The problem is systematic because hero/skill displacement code repeatedly hand-writes sight refresh and renderer refresh instead of using one fully documented movement-displacement postcondition. + +## Root-Cause Fix + +- Changed `MapSightData.UpdateSightByPath` to call `GetAroundGridIdList(range, grid, remainCenter: true)`. +- Changed `HakureiNorwayHeroSkillUtil.CollectSuikaFallingSplashNewSightGrids` to include `landingGrid` by using `remainCenter: true`. +- Kept Suika's attack landing flow caching newly opened grids before the sight mutation, then passing those grids into `FragmentSuikaFallingSplash` for delayed fog/grid refresh. + +## Guardrail Added + +- Added `Tools/CheckMovementSightRefresh.ps1`. +- Expanded `Tools/CheckSuikaFallingSplashAnimation.ps1`. + +The guardrails now check that: + +- `UpdateSightByPath` includes the standing grid. +- Suika falling splash landing sight cache includes the final landing grid. +- Suika attack landing caches sight before data reposition, updates sight at `landingGrid`, and hands cached grids to the landing fragment. + +## Verification Performed + +- `Tools/CheckMovementSightRefresh.ps1` +- `Tools/CheckSuikaFallingSplashAnimation.ps1` + +## Remaining Validation Gaps + +- Unity Editor validation is still needed for the exact animation/timing case: Suika Lv4 attacks a 3-grid target, target survives, Suika lands on an adjacent side grid that was previously fogged, and the final standing grid plus surrounding sight open after the landing. diff --git a/MD/ChronicIssueList/index.md b/MD/ChronicIssueList/index.md index 9a7637ee8..53508e845 100644 --- a/MD/ChronicIssueList/index.md +++ b/MD/ChronicIssueList/index.md @@ -6,6 +6,7 @@ | ID | 日期 | 状态 | 严重度 | 问题 | 根治方向 | 记录 | | --- | --- | --- | --- | --- | --- | --- | +| TH1-CI-2026-06-29-001 | 2026-06-29 | Fixed in code; guardrail added; Unity validation pending | High | Movement/skill displacement sight refresh could fail to reveal the unit's final standing grid, exposed by Suika Lv4 falling splash side landing after a non-lethal 3-grid attack | Make the shared movement sight path include the standing grid and guard Suika landing sight handoff | [record](2026-06-29-movement-displacement-sight-refresh.md) | | TH1-CI-2026-06-28-002 | 2026-06-28 | Fixed in code; guardrail added; Unity validation pending | High | Momiji hunter movement could lose 2 movement when another rule replaced the movement range | Split normal movement from prey-adjacent hunter target movement and ban the old global add-then-payback formula | [record](2026-06-28-momiji-hunter-movement-payback.md) | | TH1-CI-2026-06-28-001 | 2026-06-28 | Fixed in code; guardrail added; Unity validation pending | High | Aunn twin levels could diverge when one body was on a ship and the other upgraded or later landed | Treat Aunn level as a player-hero invariant over all same-player land and ship bodies, synchronized after upgrade and landing | [record](2026-06-28-aunn-ship-level-sync.md) | | TH1-CI-2026-06-27-001 | 2026-06-27 | Fixed in code; guardrail added; Unity validation pending | High | Suika Lv4 falling splash water restriction applied to empty grids but not unit targets on water | Share falling-splash water target validation across ground targeting, unit targeting, and `UnitAttack` CheckCan filtering | [record](2026-06-27-suika-falling-splash-water-targeting.md) | diff --git a/Tools/CheckMovementSightRefresh.ps1 b/Tools/CheckMovementSightRefresh.ps1 new file mode 100644 index 000000000..e76fa369a --- /dev/null +++ b/Tools/CheckMovementSightRefresh.ps1 @@ -0,0 +1,65 @@ +param( + [string]$PlayerDataFile = "Unity/Assets/Scripts/TH1_Data/PlayerData.cs", + [string]$SkillFile = "Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs", + [string]$ActionFile = "Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = git rev-parse --show-toplevel 2>$null +if (-not $repoRoot) { + throw "Not inside a git repository." +} +$repoRoot = [System.IO.Path]::GetFullPath($repoRoot.Trim()) + +function Read-RepoFile([string]$relativePath) { + $fullPath = Join-Path $repoRoot $relativePath + if (-not (Test-Path -LiteralPath $fullPath)) { + throw "Required file not found: $fullPath" + } + return Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8 +} + +function Assert-Contains([string]$text, [string]$needle, [string]$label) { + if (-not $text.Contains($needle)) { + throw "Movement sight refresh guardrail failed: $label missing '$needle'." + } +} + +$playerDataText = Read-RepoFile $PlayerDataFile +$skillText = Read-RepoFile $SkillFile +$actionText = Read-RepoFile $ActionFile + +$updateSightByPath = [regex]::Match($playerDataText, '(?ms)public bool UpdateSightByPath\(UnitData unit,PlayerData player,Vector2Int pos,MapData map\)\s*\{[\s\S]*?return true;\s*\}') +if (-not $updateSightByPath.Success) { + throw "Movement sight refresh guardrail failed: cannot locate UpdateSightByPath." +} + +Assert-Contains $updateSightByPath.Value 'GetAroundGridIdList(range, grid, remainCenter: true)' 'UpdateSightByPath must reveal the unit standing grid' +Assert-Contains $updateSightByPath.Value 'unit.HeroTask(map)?.OnExploredGrids(map, unit, count);' 'UpdateSightByPath must keep hero exploration task accounting' +Assert-Contains $updateSightByPath.Value 'OnAnyExploredGrids(map, unit, count)' 'UpdateSightByPath must keep global hero exploration task accounting' + +$suikaLandingSight = [regex]::Match($skillText, '(?ms)public static List CollectSuikaFallingSplashNewSightGrids\(MapData map, UnitData suika,\s*PlayerData player, GridData landingGrid\).*?^\s*private static void ExecuteSuikaFallingSplashDamage', [System.Text.RegularExpressions.RegexOptions]::Multiline) +if (-not $suikaLandingSight.Success) { + throw "Movement sight refresh guardrail failed: cannot locate CollectSuikaFallingSplashNewSightGrids." +} + +Assert-Contains $suikaLandingSight.Value 'GetAroundGridIdList(range, landingGrid, remainCenter: true)' 'Suika landing sight refresh must include final landing grid' + +$suikaFlyAfterAttack = [regex]::Match($skillText, '(?ms)public static bool TryExecuteSuikaFlyAfterAttack\(MapData map, AttackInfo attackInfo\).*?^\s*public static List CollectSuikaFallingSplashNewSightGrids', [System.Text.RegularExpressions.RegexOptions]::Multiline) +if (-not $suikaFlyAfterAttack.Success) { + throw "Movement sight refresh guardrail failed: cannot locate TryExecuteSuikaFlyAfterAttack." +} + +if ($suikaFlyAfterAttack.Value -notmatch 'CollectSuikaFallingSplashNewSightGrids\(map, suika, attackInfo\.OriginPlayer,\s*landingGrid\)[\s\S]*?TryRepositionUnitWithoutMoveSideEffects\(map, suika, landingGrid\)[\s\S]*?UpdateSightByPath\(suika, attackInfo\.OriginPlayer, landingGrid\.Pos\.V2\(\), map\)') { + throw "Movement sight refresh guardrail failed: Suika attack landing must cache new sight before data reposition and update sight at final landing grid." +} + +$unitAttackAction = [regex]::Match($actionText, '(?ms)private static bool TryCreateSuikaFallingSplashFragment\(.*?^\s*public override bool CheckCan', [System.Text.RegularExpressions.RegexOptions]::Multiline) +if (-not $unitAttackAction.Success) { + throw "Movement sight refresh guardrail failed: cannot locate Suika falling splash fragment handoff in UnitAttackAction." +} + +Assert-Contains $unitAttackAction.Value 'sightRefreshGrids: attackInfo.SuikaFallingSplashSightRefreshGrids' 'UnitAttackAction must hand cached landing sight grids to Suika fragment' + +Write-Host "Movement sight refresh guardrail passed." diff --git a/Tools/CheckSuikaFallingSplashAnimation.ps1 b/Tools/CheckSuikaFallingSplashAnimation.ps1 index 0eeb4410c..ecc4e123f 100644 --- a/Tools/CheckSuikaFallingSplashAnimation.ps1 +++ b/Tools/CheckSuikaFallingSplashAnimation.ps1 @@ -4,6 +4,7 @@ param( [string]$FragmentFile = "Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentSuikaFallingSplash.cs", [string]$FragmentDataFile = "Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentData.cs", [string]$AttackGroundFragmentFile = "Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentAttackGround.cs", + [string]$PlayerDataFile = "Unity/Assets/Scripts/TH1_Data/PlayerData.cs", [string]$UnitLogicFile = "Unity/Assets/Scripts/TH1_Logic/Unit/UnitLogic.cs", [string]$FragmentManagerFile = "Unity/Assets/Scripts/TH1_Anim/FragmentManager.cs", [string]$AtomFile = "Unity/Assets/Scripts/TH1_Anim/UnitAtomAnim/UnitAtomAnim.cs", @@ -31,6 +32,7 @@ $actionText = Read-RepoFile $ActionFile $fragmentText = Read-RepoFile $FragmentFile $fragmentDataText = Read-RepoFile $FragmentDataFile $attackGroundFragmentText = Read-RepoFile $AttackGroundFragmentFile +$playerDataText = Read-RepoFile $PlayerDataFile $unitLogicText = Read-RepoFile $UnitLogicFile $fragmentManagerText = Read-RepoFile $FragmentManagerFile $atomText = Read-RepoFile $AtomFile @@ -61,6 +63,7 @@ Assert-Contains $fragmentText 'RefreshFinalState' 'final refresh' Assert-Contains $fragmentText 'RefreshLandingSight' 'landing sight refresh' Assert-Contains $fragmentText 'foreach (var grid in Data.SightRefreshGrids)' 'new sight grid iteration' Assert-Contains $fragmentText 'gridRenderer?.InstantUpdateGrid(true)' 'new sight grid force refresh' +Assert-Contains $playerDataText 'GetAroundGridIdList(range, grid, remainCenter: true)' 'movement sight includes standing grid' Assert-Contains $fragmentDataText 'public List SightRefreshGrids;' 'fragment sight refresh data' Assert-Contains $unitLogicText 'public List SuikaFallingSplashSightRefreshGrids;' 'attack info sight refresh data' Assert-Contains $actionText 'sightRefreshGrids: attackInfo.SuikaFallingSplashSightRefreshGrids' 'attack fragment sight refresh handoff' @@ -72,6 +75,7 @@ Assert-Contains $actionText 'FragmentType.SuikaFallingSplash' 'attack action fra Assert-Contains $actionText 'visualCollector?.FlushTo(suikaFallingSplashFragment)' 'splash damage visual collection' Assert-Contains $skillText 'TryRepositionUnitWithoutMoveSideEffects(map, suika, target)' 'ground self-jump data-only reposition' Assert-Contains $skillText 'CollectSuikaFallingSplashNewSightGrids' 'Suika sight refresh collector' +Assert-Contains $skillText 'GetAroundGridIdList(range, landingGrid, remainCenter: true)' 'Suika landing sight includes final grid' Assert-Contains $skillText 'attackInfo.IsSuikaFallingSplash = true;' 'skill attack marker' Assert-Contains $skillText 'attackInfo.SuikaFallingSplashFinalGrid = landingGrid;' 'skill final grid marker' Assert-Contains $skillText 'ExecuteSuikaFallingSplashDamage(map, suika, targetGrid, AnimPhase.AttackImpact + 10)' 'post-impact splash visual phase' diff --git a/Unity/Assets/Scripts/TH1_Data/PlayerData.cs b/Unity/Assets/Scripts/TH1_Data/PlayerData.cs index c7a335fd2..7fe0a2634 100644 --- a/Unity/Assets/Scripts/TH1_Data/PlayerData.cs +++ b/Unity/Assets/Scripts/TH1_Data/PlayerData.cs @@ -1576,7 +1576,7 @@ namespace RuntimeData return false; } var range = unit?.GetSightRange(map,grid) ?? 1; - var list = map.GridMap.GetAroundGridIdList(range,grid); + var list = map.GridMap.GetAroundGridIdList(range, grid, remainCenter: true); var count = Main.PlayerLogic.UpdateSight_LogicView(map, player, list); unit.HeroTask(map)?.OnExploredGrids(map, unit, count); foreach (var kv in player.PlayerHeroData.HeroTaskDict) kv.Value.OnAnyExploredGrids(map, unit, count); diff --git a/Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs b/Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs index 9dd2020b8..8e0a388a5 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs @@ -1991,7 +1991,7 @@ namespace Logic.Skill { if (map == null || suika == null || player?.Sight == null || landingGrid == null) return null; var range = suika.GetSightRange(map, landingGrid); - var gridIds = map.GridMap.GetAroundGridIdList(range, landingGrid); + var gridIds = map.GridMap.GetAroundGridIdList(range, landingGrid, remainCenter: true); var result = new List(); foreach (var gid in gridIds) {