@@ -194,7 +194,9 @@ class TestNegativeCostReprojection:
194194
195195 @staticmethod
196196 def _make_negative_frame (fill_width : int ) -> np .ndarray :
197- """1280x720 BGR 帧:费用条填充 + 减号标记(触发 detect_negative_cost)。"""
197+ """1280x720 BGR 帧:费用条填充 + 减号标记(触发 detect_negative_cost)+ InBattle glyph。"""
198+ from aao .core .timing .battle_state import _battle_button_scale , _pause_glyph_rect
199+
198200 roi = tick .find_cost_bar_roi (1280 , 720 )
199201 x1 , x2 , y = roi
200202 frame = np .full ((720 , 1280 , 3 ), 150 , dtype = np .uint8 )
@@ -203,6 +205,16 @@ def _make_negative_frame(fill_width: int) -> np.ndarray:
203205 # 减号 ROI 全白 → detect_negative_cost 返回 True
204206 sx , sy , sw , sh = config .COST_SIGN_ROI
205207 frame [sy : sy + sh , sx : sx + sw ] = 255
208+ # 暂停 glyph ROI 填充亮像素(InBattle 信号)
209+ scale = _battle_button_scale (1280 , 720 )
210+ gl , gr , gt , gb = _pause_glyph_rect (1280 , 720 , scale )
211+ filled = 0
212+ for gy in range (gt , gb ):
213+ for gx in range (gl , gr ):
214+ if filled >= 635 :
215+ break
216+ frame [gy , gx ] = 255
217+ filled += 1
206218 return frame
207219
208220 @staticmethod
@@ -331,3 +343,370 @@ def test_from_dict_v2_no_migration(self) -> None:
331343 assert pm ["0" ] == 0
332344 assert pm ["5" ] == 1
333345 assert pm ["10" ] == 3
346+
347+
348+ class TestOcclusionNone :
349+ """遮挡检测(tick 层):灰色遮挡物截断白色填充 → None → hold。
350+
351+ _contiguous_fill_width 检测到 edge 后仍有白色像素 → 费用条被遮挡 → None。
352+ 正常费用条末端(edge 后全灰,无白色)→ 返回 edge。
353+ """
354+
355+ @staticmethod
356+ def _make_calibration (total : int = 30 ) -> FullCalibrationData :
357+ pixel_map = {str (w ): w - 1 for w in range (1 , total )} | {"0" : 0 }
358+ return FullCalibrationData (
359+ detection_mode = "single" ,
360+ profiles = [CalibrationProfile (total_frames = total , pixel_map = pixel_map )],
361+ screen_width = 1280 ,
362+ screen_height = 720 ,
363+ )
364+
365+ @staticmethod
366+ def _make_cost_bar_frame (fill_width : int ) -> np .ndarray :
367+ """构造有费用条填充的帧 + InBattle 暂停 glyph。"""
368+ from aao .core .timing import tick as tick_mod
369+ from aao .core .timing .battle_state import _battle_button_scale , _pause_glyph_rect
370+
371+ roi = tick_mod .find_cost_bar_roi (1280 , 720 )
372+ x1 , x2 , y = roi
373+ frame = np .full ((720 , 1280 , 3 ), 150 , dtype = np .uint8 )
374+ if fill_width > 0 :
375+ frame [y , x1 : x1 + fill_width ] = 255
376+ scale = _battle_button_scale (1280 , 720 )
377+ gl , gr , gt , gb = _pause_glyph_rect (1280 , 720 , scale )
378+ filled = 0
379+ for gy in range (gt , gb ):
380+ for gx in range (gl , gr ):
381+ if filled >= 635 :
382+ break
383+ frame [gy , gx ] = 255
384+ filled += 1
385+ return frame
386+
387+ def test_gray_occluder_returns_none (self ) -> None :
388+ """灰色遮挡物夹在白色填充中间 → None(费用条被遮挡)。"""
389+ from aao .core .timing import tick as tick_mod
390+ from aao .core .timing .tick import get_filled_pixel_width
391+
392+ roi = tick_mod .find_cost_bar_roi (1280 , 720 )
393+ x1 , x2 , y = roi
394+ bar_w = x2 - x1
395+ frame = self ._make_cost_bar_frame (bar_w ) # 填满整条
396+ # 在费用条 1/3 处插入灰色遮挡物,遮挡物之后仍有白色
397+ mid = x1 + bar_w // 3
398+ frame [y , mid : mid + 5 ] = 150 # 灰色遮挡物
399+ pw = get_filled_pixel_width (frame , roi )
400+ assert pw is None
401+
402+ def test_non_gray_occluder_returns_none (self ) -> None :
403+ """非灰度遮挡物夹在白色填充中间 → None。"""
404+ from aao .core .timing import tick as tick_mod
405+ from aao .core .timing .tick import get_filled_pixel_width
406+
407+ roi = tick_mod .find_cost_bar_roi (1280 , 720 )
408+ x1 , x2 , y = roi
409+ bar_w = x2 - x1
410+ frame = self ._make_cost_bar_frame (bar_w )
411+ mid = x1 + bar_w // 3
412+ frame [y , mid : mid + 5 ] = [0 , 0 , 255 ] # 彩色遮挡物
413+ pw = get_filled_pixel_width (frame , roi )
414+ assert pw is None
415+
416+ def test_normal_fill_returns_width (self ) -> None :
417+ """正常费用条(白色填充 + 灰色末端)→ 返回正确宽度。"""
418+ from aao .core .timing import tick as tick_mod
419+ from aao .core .timing .tick import get_filled_pixel_width
420+
421+ roi = tick_mod .find_cost_bar_roi (1280 , 720 )
422+ frame = self ._make_cost_bar_frame (200 )
423+ pw = get_filled_pixel_width (frame , roi )
424+ assert pw is not None
425+ assert pw > 0
426+
427+ def test_occlusion_hold_in_time_source (self ) -> None :
428+ """遮挡 → lf=None → TimeSource hold previous_frame,不跳帧。"""
429+ cal = self ._make_calibration ()
430+ ts = TimeSource (cal )
431+ ts .update (self ._make_cost_bar_frame (15 ))
432+ held = ts .previous_frame
433+ from aao .core .timing import tick as tick_mod
434+
435+ roi = tick_mod .find_cost_bar_roi (1280 , 720 )
436+ x1 , x2 , y = roi
437+ bar_w = x2 - x1
438+ frame = self ._make_cost_bar_frame (bar_w )
439+ mid = x1 + bar_w // 3
440+ frame [y , mid : mid + 5 ] = 150 # 灰色遮挡物
441+ result = ts .update (frame )
442+ assert result == held # hold,不跳
443+ assert ts .previous_frame == held
444+
445+ def test_wrap_still_works (self ) -> None :
446+ """正常 wrap(lf 29→1)→ cycle_counter++,遮挡检测不干扰。"""
447+ cal = self ._make_calibration ()
448+ ts = TimeSource (cal )
449+ ts .update (self ._make_cost_bar_frame (29 ))
450+ assert ts .cycle_counter == 0
451+ ts .update (self ._make_cost_bar_frame (1 ))
452+ assert ts .cycle_counter == 1
453+
454+
455+ class TestOcclusionHoldTimeSource :
456+ """遮挡 hold(time_source 层):pw=0 伪装成空费用条 → lf=0。
457+
458+ 实际遮挡场景(来自游戏日志):
459+ - 费用条左端被灰色遮挡物盖住 → pw=0 → lf=0
460+ - 但 _prev_measured 在中间值(非满条)→ 不是正常 wrap → hold
461+
462+ 遮挡 hold 规则:
463+ - lf 大幅回退(> _WRAP_MARGIN)
464+ - 且 lf 在中间值(>= _WRAP_MARGIN)→ hold(遮挡导致读到偏小值)
465+ - 或 lf 接近 0(< _WRAP_MARGIN)但 prev 不在满条附近 → hold(遮挡导致 pw=0)
466+ - 正常 wrap(prev 在满条附近 → lf=0)→ 不 hold
467+ - 负费切换(neg_changed)→ 不 hold
468+ """
469+
470+ @staticmethod
471+ def _make_calibration (total : int = 30 ) -> FullCalibrationData :
472+ pixel_map = {str (w ): w - 1 for w in range (1 , total )} | {"0" : 0 }
473+ return FullCalibrationData (
474+ detection_mode = "single" ,
475+ profiles = [CalibrationProfile (total_frames = total , pixel_map = pixel_map )],
476+ screen_width = 1280 ,
477+ screen_height = 720 ,
478+ )
479+
480+ @staticmethod
481+ def _make_frame (fill_width : int ) -> np .ndarray :
482+ """InBattle 帧 + 费用条填充。fill_width=0 → 空费用条(全灰)。"""
483+ from aao .core .timing import tick as tick_mod
484+ from aao .core .timing .battle_state import _battle_button_scale , _pause_glyph_rect
485+
486+ roi = tick_mod .find_cost_bar_roi (1280 , 720 )
487+ x1 , x2 , y = roi
488+ frame = np .full ((720 , 1280 , 3 ), 150 , dtype = np .uint8 )
489+ if fill_width > 0 :
490+ frame [y , x1 : x1 + fill_width ] = 255
491+ scale = _battle_button_scale (1280 , 720 )
492+ gl , gr , gt , gb = _pause_glyph_rect (1280 , 720 , scale )
493+ filled = 0
494+ for gy in range (gt , gb ):
495+ for gx in range (gl , gr ):
496+ if filled >= 635 :
497+ break
498+ frame [gy , gx ] = 255
499+ filled += 1
500+ return frame
501+
502+ @staticmethod
503+ def _width_for_frame (target_lf : int , total : int = 30 ) -> int :
504+ """display frame → 像素宽度(查测试 calibration 的 pixel_map 反转)。
505+
506+ 测试用 pixel_map: {0:0, 1:0, 2:1, ..., 29:28}。
507+ display = internal+1(width=0 → display=0)。
508+ key=w, value=w-1, display=w-1+1=w → display=target_lf → key=target_lf。
509+ """
510+ if target_lf == 0 :
511+ return 0
512+ return target_lf # pixel_map key=target_lf → display=target_lf
513+
514+ def test_occlusion_pw0_mid_value_holds (self ) -> None :
515+ """遮挡 pw=0:lf 16→0(prev 不满条)→ hold,不 wrap。"""
516+ cal = self ._make_calibration ()
517+ ts = TimeSource (cal )
518+ # 喂到 lf=16(中间值)
519+ w16 = self ._width_for_frame (16 )
520+ ts .update (self ._make_frame (w16 ))
521+ assert ts .previous_frame >= 0
522+ held = ts .previous_frame
523+ # 遮挡 → 空费用条 → lf=0
524+ ts .update (self ._make_frame (0 ))
525+ assert ts .cycle_counter == 0 # 不 wrap
526+ assert ts .previous_frame == held # hold
527+
528+ def test_occlusion_pw0_high_value_holds (self ) -> None :
529+ """遮挡 pw=0:lf 23→0(prev 不满条)→ hold。"""
530+ cal = self ._make_calibration ()
531+ ts = TimeSource (cal )
532+ w23 = self ._width_for_frame (23 )
533+ ts .update (self ._make_frame (w23 ))
534+ held = ts .previous_frame
535+ ts .update (self ._make_frame (0 ))
536+ assert ts .cycle_counter == 0
537+ assert ts .previous_frame == held
538+
539+ def test_normal_wrap_from_full_not_held (self ) -> None :
540+ """正常 wrap:lf 29→0(prev 在满条附近)→ wrap,不 hold。"""
541+ cal = self ._make_calibration ()
542+ ts = TimeSource (cal )
543+ w29 = self ._width_for_frame (29 )
544+ ts .update (self ._make_frame (w29 ))
545+ assert ts .cycle_counter == 0
546+ ts .update (self ._make_frame (0 ))
547+ assert ts .cycle_counter == 1 # wrap
548+
549+ def test_normal_wrap_from_near_full_not_held (self ) -> None :
550+ """正常 wrap:lf 27→0(prev=27 >= 27)→ wrap。"""
551+ cal = self ._make_calibration ()
552+ ts = TimeSource (cal )
553+ w27 = self ._width_for_frame (27 )
554+ ts .update (self ._make_frame (w27 ))
555+ ts .update (self ._make_frame (0 ))
556+ assert ts .cycle_counter == 1
557+
558+ def test_occlusion_mid_value_to_mid_value_holds (self ) -> None :
559+ """遮挡 pw=偏小值:lf 22→15(中间值回退)→ hold。"""
560+ cal = self ._make_calibration ()
561+ ts = TimeSource (cal )
562+ w22 = self ._width_for_frame (22 )
563+ w15 = self ._width_for_frame (15 )
564+ ts .update (self ._make_frame (w22 ))
565+ held = ts .previous_frame
566+ ts .update (self ._make_frame (w15 ))
567+ assert ts .cycle_counter == 0
568+ assert ts .previous_frame == held
569+
570+ def test_occlusion_recovery (self ) -> None :
571+ """遮挡恢复:lf 16→0(hold)→17(恢复) → 正常更新。"""
572+ cal = self ._make_calibration ()
573+ ts = TimeSource (cal )
574+ w16 = self ._width_for_frame (16 )
575+ ts .update (self ._make_frame (w16 ))
576+ # 遮挡
577+ ts .update (self ._make_frame (0 ))
578+ assert ts .cycle_counter == 0
579+ # 恢复
580+ w17 = self ._width_for_frame (17 )
581+ ts .update (self ._make_frame (w17 ))
582+ assert ts .cycle_counter == 0 # 仍未 wrap
583+ assert ts .previous_frame >= 0 # 正常更新
584+
585+
586+ class TestBattleStateIntegration :
587+ """清零规则:费用条可见性 + BattleBegin 双路径。
588+
589+ - InBattle → arm + 读费用条
590+ - NotInBattle + 费用条可见(暂停)→ 不清零
591+ - NotInBattle + 费用条不可见(lf=None)连续 >= 30 帧 → 标记重入清零
592+ - 重新 InBattle + 标记 → reset(进关卡清计时器)
593+ - BattleBegin + armed → reset + disarm
594+ """
595+
596+ @staticmethod
597+ def _make_calibration (total : int = 30 ) -> FullCalibrationData :
598+ pixel_map = {str (w ): w - 1 for w in range (1 , total )} | {"0" : 0 }
599+ return FullCalibrationData (
600+ detection_mode = "single" ,
601+ profiles = [CalibrationProfile (total_frames = total , pixel_map = pixel_map )],
602+ screen_width = 1280 ,
603+ screen_height = 720 ,
604+ )
605+
606+ @staticmethod
607+ def _make_in_battle_frame (fill_width : int ) -> np .ndarray :
608+ """InBattle 帧:暂停 glyph ROI 亮 + 费用条填充。
609+
610+ 1280x720: 暂停 glyph ROI (1188, 1230, 38, 69)。
611+ 需要亮像素归一化面积 ~635(播放区间 [560,710])→ 填充 635 个亮像素。
612+ """
613+ from aao .core .timing import tick as tick_mod
614+ from aao .core .timing .battle_state import _battle_button_scale , _pause_glyph_rect
615+
616+ roi = tick_mod .find_cost_bar_roi (1280 , 720 )
617+ x1 , x2 , y = roi
618+ frame = np .full ((720 , 1280 , 3 ), 150 , dtype = np .uint8 )
619+ if fill_width > 0 :
620+ frame [y , x1 : x1 + fill_width ] = 255
621+ # 暂停 glyph ROI 填充亮像素(播放状态)
622+ scale = _battle_button_scale (1280 , 720 )
623+ gl , gr , gt , gb = _pause_glyph_rect (1280 , 720 , scale )
624+ # 填充前 635 个像素(归一化面积 635,在 [560,710] 内)
625+ filled = 0
626+ target = 635
627+ for gy in range (gt , gb ):
628+ for gx in range (gl , gr ):
629+ if filled >= target :
630+ break
631+ frame [gy , gx ] = 255
632+ filled += 1
633+ if filled >= target :
634+ break
635+ return frame
636+
637+ @staticmethod
638+ def _make_not_in_battle_frame () -> np .ndarray :
639+ """NotInBattle 帧:两个 glyph ROI 全暗 + 费用条 ROI 末端非灰度 → None。
640+
641+ 全黑帧会让 get_filled_pixel_width 返回 0 → lf=0 → 误 wrap。
642+ 末端放彩色像素 → None → hold,模拟真实遮挡场景。
643+ """
644+ from aao .core .timing import tick as tick_mod
645+
646+ frame = np .full ((720 , 1280 , 3 ), 0 , dtype = np .uint8 )
647+ roi = tick_mod .find_cost_bar_roi (1280 , 720 )
648+ x1 , x2 , y = roi
649+ frame [y , x2 - 1 ] = [200 , 0 , 0 ] # 末端非灰度 → None
650+ return frame
651+
652+ def test_in_battle_reads_cost_bar_and_arms (self ) -> None :
653+ """InBattle 帧 + 费用条填充 → 正常读帧 + arm。"""
654+ cal = self ._make_calibration ()
655+ ts = TimeSource (cal )
656+ result = ts .update (self ._make_in_battle_frame (15 ))
657+ assert result is not None
658+ assert ts .previous_frame >= 0
659+ assert ts ._battle_begin_reset_armed is True
660+
661+ def test_not_in_battle_with_cost_bar_no_reset (self ) -> None :
662+ """NotInBattle + 费用条仍可见(暂停)→ 不标记清零。"""
663+ cal = self ._make_calibration ()
664+ ts = TimeSource (cal )
665+ ts .update (self ._make_in_battle_frame (15 ))
666+ # NotInBattle 但费用条有填充(暂停时费用条可见)
667+ from aao .core .timing import tick as tick_mod
668+
669+ roi = tick_mod .find_cost_bar_roi (1280 , 720 )
670+ x1 , x2 , y = roi
671+ for _ in range (100 ):
672+ # 全灰背景 + 费用条填充(末端灰度 → get_filled_pixel_width 非 None)
673+ frame = np .full ((720 , 1280 , 3 ), 150 , dtype = np .uint8 )
674+ frame [y , x1 : x1 + 15 ] = 255 # 费用条填充 → lf != None
675+ ts .update (frame )
676+ assert ts ._needs_reentry_reset is False # 未标记清零
677+
678+ def test_not_in_battle_no_cost_bar_marks_reset (self ) -> None :
679+ """NotInBattle + 费用条不可见(lf=None)连续 >= 180 帧 → 标记清零。"""
680+ cal = self ._make_calibration ()
681+ ts = TimeSource (cal )
682+ ts .update (self ._make_in_battle_frame (15 ))
683+ assert ts ._needs_reentry_reset is False
684+ # 180 帧 NotInBattle + 费用条不可见(None)
685+ for _ in range (180 ):
686+ ts .update (self ._make_not_in_battle_frame ())
687+ assert ts ._needs_reentry_reset is True # 标记清零
688+
689+ def test_reentry_in_battle_resets (self ) -> None :
690+ """标记清零后 → InBattle → reset(进关卡清计时器)。"""
691+ cal = self ._make_calibration ()
692+ ts = TimeSource (cal )
693+ ts .update (self ._make_in_battle_frame (15 ))
694+ assert ts ._total_frames > 0
695+ # 180 帧 NotInBattle + 费用条不可见 → 标记
696+ for _ in range (180 ):
697+ ts .update (self ._make_not_in_battle_frame ())
698+ assert ts ._needs_reentry_reset is True
699+ # 重新 InBattle → reset
700+ ts .update (self ._make_in_battle_frame (5 ))
701+ assert ts ._needs_reentry_reset is False
702+ assert ts .cycle_base_frames == 0
703+ assert ts .timer_offset == 0
704+
705+ def test_short_no_cost_bar_no_reset (self ) -> None :
706+ """NotInBattle + 费用条不可见 < 180 帧 → 不标记清零。"""
707+ cal = self ._make_calibration ()
708+ ts = TimeSource (cal )
709+ ts .update (self ._make_in_battle_frame (15 ))
710+ for _ in range (179 ):
711+ ts .update (self ._make_not_in_battle_frame ())
712+ assert ts ._needs_reentry_reset is False # 未到阈值
0 commit comments