fix: block Suika falling splash water unit targets

This commit is contained in:
daixiawu 2026-06-27 17:08:08 +08:00
parent 7d2b7ef9d5
commit 78c72adba4
4 changed files with 148 additions and 4 deletions

View File

@ -0,0 +1,59 @@
# TH1-CI-2026-06-27-001 - Suika falling splash water unit targeting
- Status: Fixed in code; guardrail added; Unity validation pending
- First recorded date: 2026-06-27
- Severity: High
## Raw Symptom
伊吹萃香 Lv4 的“从天而降”已禁止直接选择水域空格,但仍可选择 3 格距离外位于海上的敌方单位,并执行天降攻击。
Affected paths:
- `Unity/Assets/Scripts/TH1_Logic/Unit/UnitLogic.cs` move/attack target classification
- `Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs` `UnitAttackAction.CheckCan`
- `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs`
## Why This Is Recurring
Suika falling splash has two target paths:
- empty-grid targeting via `IsCanAttackTargetGrid` / `CanSuikaFlyToGrid`
- enemy-unit targeting via `CanSuikaFlyAttackTarget` and the normal `UnitAttack` action
The earlier water fix only guarded the empty-grid path by checking the landing grid. The unit-target path still checked level, attack point, enemy, distance, and sight, but did not apply the same water-target rule before presenting or accepting the unit attack.
## Root Cause
The ability-specific target contract was split between grid targeting and unit targeting. `UnitAttackAction.CheckCan` relies on `UnitData.IsCanAttackTargetUnit`, but `SuikaFallingSplashSkill` did not provide a negative filter for invalid 3-range falling-splash unit attacks.
## Root-Cause Fix
- Added a shared `CanUseGridAsSuikaFallingSplashTarget` predicate that rejects `TerrainType.ShallowSea` and `TerrainType.DeepSea`.
- Applied it to `CanSuikaFlyToGrid` so empty water targets stay blocked.
- Applied it to `CanSuikaFlyAttackTarget` so units standing on water targets are not presented as attackable.
- Added `SuikaFallingSplashSkill.IsCanAttackTargetUnit` so directly constructed `UnitAttack` params are rejected by authoritative `CheckCan` when they represent a 3-range falling-splash attack.
## Guardrail Added
Added `Tools/CheckSuikaFallingSplashTargeting.ps1`.
The script verifies that:
- both ground and unit falling-splash target paths reuse the water-target predicate
- the predicate rejects shallow/deep sea
- `SuikaFallingSplashSkill.IsCanAttackTargetUnit` gates `UnitAttackAction.CheckCan`
- `UnitLogic` still uses `CanSuikaFlyAttackTarget` for UI/AI target classification
## Verification Performed
- `Tools/CheckSuikaFallingSplashTargeting.ps1`
- `dotnet build Unity/TH1.Hotfix.csproj --no-restore`
## Remaining Validation Gaps
Unity Editor scenario still needs manual validation:
- Lv4 Suika cannot select an enemy naval unit exactly 3 grids away on shallow/deep sea.
- Lv4 Suika can still select a valid enemy unit exactly 3 grids away on land.
- Lv4 Suika cannot select shallow/deep sea empty targets.

View File

@ -6,6 +6,7 @@
| ID | 日期 | 状态 | 严重度 | 问题 | 根治方向 | 记录 |
| --- | --- | --- | --- | --- | --- | --- |
| 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) |
| TH1-CI-2026-06-26-002 | 2026-06-26 | Fixed in code; guardrail added; Unity validation pending | Critical | Aunn shared HP ignored `DamageBearer` substitute-damage paths | Treat shared HP as an invariant over the actual mutated unit, `DamageBearer ?? DamageTarget`, with entrypoint fallback and shared-death cleanup | [record](2026-06-26-aunn-shared-health-damage-bearer.md) |
| TH1-CI-2026-06-26-001 | 2026-06-26 | Fixed in code; guardrail added; Unity validation pending | High | Suika Lv4 falling splash can move data without complete landing, projectile, and fog/sight presentation after a 3-grid jump | Use a dedicated Suika falling-splash Fragment that owns jump, splash, final landing, and newly opened fog refresh | [record](2026-06-26-suika-falling-splash-landing-animation.md) |
| TH1-CI-2026-06-25-004 | 2026-06-25 | Fixed after correcting Aunn 331/332/342/344 source-buff map; Unity validation pending | Critical | Aunn hero source skills and same-name runtime buffs were conflated and edited without a confirmed skill source map | Enforce 331 as defense-only runtime buff, 332 as hidden approach state, 342 as Lv3+ Aunn Unique/source skill, and 344 as same-name Positive beneficiary damage-bearing buff | [record](2026-06-25-aunn-lv3-petrified-defense-aura-display.md) |

View File

@ -0,0 +1,61 @@
param(
[string]$SkillFile = "Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs",
[string]$UnitLogicFile = "Unity/Assets/Scripts/TH1_Logic/Unit/UnitLogic.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 "Suika falling splash targeting guardrail failed: $label missing '$needle'."
}
}
$skillText = Read-RepoFile $SkillFile
$unitLogicText = Read-RepoFile $UnitLogicFile
Assert-Contains $skillText 'private static bool CanUseGridAsSuikaFallingSplashTarget' 'shared target grid predicate'
Assert-Contains $skillText 'grid.Terrain is TerrainType.ShallowSea or TerrainType.DeepSea' 'water target rejection'
Assert-Contains $skillText 'public static bool IsSuikaFallingSplashAttackAttempt' 'falling splash attack attempt predicate'
Assert-Contains $skillText 'public override bool IsCanAttackTargetUnit' 'authoritative UnitAttack CheckCan gate'
Assert-Contains $unitLogicText 'HakureiNorwayHeroSkillUtil.CanSuikaFlyAttackTarget' 'move/attack UI and AI entrypoint'
$flyGridMethod = [regex]::Match($skillText, '(?ms)public static bool CanSuikaFlyToGrid\(MapData map, UnitData suika, GridData target\).*?^\s*public static bool CanSuikaFlyAttackTarget')
if (-not $flyGridMethod.Success) {
throw "Suika falling splash targeting guardrail failed: cannot locate CanSuikaFlyToGrid body."
}
if ($flyGridMethod.Value -notmatch 'CanUseGridAsSuikaFallingSplashTarget\(map, suika, target\)[\s\S]*?CanUnitLandOnGrid\(map, suika, target\)') {
throw "Suika falling splash targeting guardrail failed: ground-target path must reject water before landing/occupancy checks."
}
$flyAttackMethod = [regex]::Match($skillText, '(?ms)public static bool CanSuikaFlyAttackTarget\(MapData map, UnitData suika, UnitData target\).*?^\s*public static bool IsSuikaFallingSplashAttackAttempt')
if (-not $flyAttackMethod.Success) {
throw "Suika falling splash targeting guardrail failed: cannot locate CanSuikaFlyAttackTarget body."
}
if ($flyAttackMethod.Value -notmatch 'IsSuikaFallingSplashAttackAttempt\(map, suika, target, out var targetGrid\)[\s\S]*?CanUseGridAsSuikaFallingSplashTarget\(map, suika, targetGrid\)') {
throw "Suika falling splash targeting guardrail failed: unit-target path must reuse the same water target predicate."
}
$skillClassMethod = [regex]::Match($skillText, '(?ms)public partial class SuikaFallingSplashSkill.*?^\s*public partial class SuikaThrowReadySkill')
if (-not $skillClassMethod.Success) {
throw "Suika falling splash targeting guardrail failed: cannot locate SuikaFallingSplashSkill body."
}
if ($skillClassMethod.Value -notmatch 'public override bool IsCanAttackTargetUnit\(MapData map, UnitData self, UnitData target\)[\s\S]*?IsSuikaFallingSplashAttackAttempt\(map, self, target, out _\)[\s\S]*?CanSuikaFlyAttackTarget\(map, self, target\)') {
throw "Suika falling splash targeting guardrail failed: SuikaFallingSplashSkill must block invalid falling-splash UnitAttack params in CheckCan."
}
Write-Host "Suika falling splash targeting guardrail passed."

View File

@ -122,6 +122,12 @@ namespace Logic.Skill
return Main.UnitLogic.CheckUnitAbleForGrid_RealTimeStatus(map, unit, grid);
}
private static bool CanUseGridAsSuikaFallingSplashTarget(MapData map, UnitData suika, GridData grid)
{
if (grid == null || grid.Terrain is TerrainType.ShallowSea or TerrainType.DeepSea) return false;
return IsUnitLandingTerrain(map, suika, grid);
}
public static bool TryFindFirstEmptyAround(MapData map, GridData center, int minRange, int maxRange,
out GridData emptyGrid)
{
@ -1784,6 +1790,7 @@ namespace Logic.Skill
if (!IsSuikaGiantThrowForm(suika)) return false;
if (suika.GetSkill(SkillType.SuikaThrowReady, out _)) return false;
if (suika.GetActionPoint(ActionPointType.Attack) <= 0) return false;
if (!CanUseGridAsSuikaFallingSplashTarget(map, suika, target)) return false;
if (!CanUnitLandOnGrid(map, suika, target)) return false;
if (target.RealUnit(map, out _)) return false;
if (!suika.Grid(map, out var suikaGrid)) return false;
@ -1794,15 +1801,24 @@ namespace Logic.Skill
public static bool CanSuikaFlyAttackTarget(MapData map, UnitData suika, UnitData target)
{
if (map == null || suika == null || target == null) return false;
if (!IsSuikaFallingSplashAttackAttempt(map, suika, target, out var targetGrid)) return false;
if (!CanUseGridAsSuikaFallingSplashTarget(map, suika, targetGrid)) return false;
var player = suika.Player(map);
return player?.Sight != null && player.Sight.CheckIsInSight(targetGrid.Id);
}
public static bool IsSuikaFallingSplashAttackAttempt(MapData map, UnitData suika, UnitData target,
out GridData targetGrid)
{
targetGrid = null;
if (map == null || suika == null || target == null) return false;
if (!IsSuikaGiantThrowForm(suika)) return false;
if (suika.GetSkill(SkillType.SuikaThrowReady, out _)) return false;
if (suika.GetActionPoint(ActionPointType.Attack) <= 0) return false;
if (!IsEnemy(map, suika, target)) return false;
if (!suika.Grid(map, out var suikaGrid) || !target.Grid(map, out var targetGrid)) return false;
if (map.GridMap.CalcDistance(suikaGrid, targetGrid) != SuikaFallingSplashRange) return false;
var player = suika.Player(map);
return player?.Sight != null && player.Sight.CheckIsInSight(targetGrid.Id);
if (!suika.Grid(map, out var suikaGrid) || !target.Grid(map, out targetGrid)) return false;
return map.GridMap.CalcDistance(suikaGrid, targetGrid) == SuikaFallingSplashRange;
}
public static bool TryExecuteSuikaFlyToGround(MapData map, UnitData suika, GridData target,
@ -3669,6 +3685,13 @@ namespace Logic.Skill
return HakureiNorwayHeroSkillUtil.TryExecuteSuikaFlyToGround(map, self, target, out animSkillType);
}
public override bool IsCanAttackTargetUnit(MapData map, UnitData self, UnitData target)
{
if (!HakureiNorwayHeroSkillUtil.IsSuikaFallingSplashAttackAttempt(map, self, target, out _))
return true;
return HakureiNorwayHeroSkillUtil.CanSuikaFlyAttackTarget(map, self, target);
}
public override void AfterActiveAttackOther(MapData mapData, AttackInfo attackInfo)
{
HakureiNorwayHeroSkillUtil.TryExecuteSuikaFlyAfterAttack(mapData, attackInfo);