# 가방 방해 복구 재사용 범위

2026-10-09 mini26 원본의 9개 사본과 본버전 bag_capacity.py·가방 glyph 2개 사본을 새 dev.2에 만들었다. `manifest.json`, `observation-manifest.json`의 원본 복사 전·후 SHA256과 사본 SHA256은 모두 일치한다. 원본 수정·게임 입력·캡처·포커스·메모리 읽기는 실행하지 않았다. 이전 개발 완료 보고를 현재 실게임 성공으로 취급하지 않는다.

## 구현

- `masukja2/bag_recovery.py`: `plan_bag_recovery(snapshot, receipt=None)` 순수 계획기와 `BagRecoveryProvider` 현재 관측 경계. `bag_geometry`, `category_scroll_reset`, `matching_result_cells`는 사본의 수식/규칙에서 정적으로 옮겼다. 런타임 리소스는 이 프로젝트 `data/arbeit-bag-source/mini26/resources`만 사용한다.
- `masukja2/live_io.py`: 이름/수량 전용 `bag_text`와 검색 포커스용 ENTER. 기존 창·프로세스 시작시각·foreground·stop/F8·외부입력 보호와 ledger를 사용한다.
- `BagRecoveryProvider`의 기본 reader는 `CurrentBagReader`다. 이미 공급된 PIL 이미지에서 기존 OfflineOCR와 source 계산 영역으로 현재 경고·제목·검색 결과를 읽고, 16:9/UI70의 current field에서 본버전 glyph+ring을 확인한다. 자체 캡처·입력·메모리 읽기는 없다. `tests/test_bag_recovery.py`는 합성 화면/주입 OCR이며 실제 게임 효과 검증을 뜻하지 않는다.

## 보존한 정책과 순서

사용자 명시 정책은 `policy_id/source='explicit_user_policy'/name/category='Ingredient'/keep`이다. 이 정책이 없으면 인벤토리에서 임의 품목을 골라 정리하지 않는다. 이름은 정확한 UTF-16 1~50자, keep은 0~2147483647 정수다. 현재 공식 재고의 inventory 초과량만 사용하고, character_storage/account_storage는 처분 수량에 포함하지 않는다. 수량은 `min(max(0, inventory-keep), max(0, inventory-locked-reserved-objective_reserved))`다. reserved는 공급된 보존 제약이고 objective_reserved는 현재 확인된 부모 목표의 필요 수량이다. 다른 category/불명확한 count·lock은 거부한다.

공식 get_items에는 예약 필드가 없다. 따라서 현재 `objective_evidence`에서 semantics=inventory.hold, 정확한 item_name, required_quantity, identity_binding 및 quest_id/step_id/objective_index가 확인된 요구량을 합산하여 별도 보호한다. 이미 보유 조건을 충족한 현재 항목도 후속 전달/보고에 필요할 수 있어 보호한다. 목표와 소유·시각이 다르거나 같은 scope가 중복이면 처분을 보류한다. 임의 Goal/Count나 번역 문구로 필요 수량을 만들지 않는다.

HUD가 사라져도 원래 목표의 품목이 정리되지 않도록 `snapshot.goal_reservations` 지속 예약 목록을 받는다. 각 활성 record는 원본 `source='get_quests'`/`observed_at`, character_id/session_id, quest_id/accept_time/step_id/objective_index, item_name/required_quantity, active=True, validated_by_current_occurrence=True 및 `current_owned_at`을 갖는다. 원래 수량 증거의 시각은 갱신하지 않는다. 현재 소유·퀘스트 발생 검증 시각인 current_owned_at만 현재 snapshot 기준 5초 이내여야 한다. 해제는 상위 실행기의 typed 전달/종료 증거가 책임진다. HUD 부재·보유 목표 완료만으로 해제하지 않는다.

동일한 quest/발생/step/objective/item의 현재·지속 예약은 두 번 합산하지 않고 더 큰 수량으로 보호한다. 현재 HUD proof에 accept_time이 없으면 같은 quest/step/objective/item의 유일한 지속 예약과 연결한다. 발생이 충돌하면 보류한다. `snapshot.goal_reservation_gaps=[{item_name,reason}]`에 사용자 정책 품목이 있으면 필요 수량 미확인으로 검색·처분을 보류한다. 정적 preload Quantity로 빈 수량을 채우지 않는다.

검색이 시작된 이후에는 최초 검색 영수증의 quantity가 처분 수량 상한이며 objective_reserved가 예약 하한이다. 새 재고·잠금으로 처분 수량이 감소할 수는 있지만 HUD 누락이나 재고 증가로 상한이 늘지 않는다. 원본 예약 proof는 이후 처분 영수증에도 원래 시각으로 남는다. 재현 사례는 미잠금 재료 30개, 실제 HUD 필요량 12개, 명시 keep=0에서 검색18 → HUD누락 → 처분18이며, 수정 전에는 처분30으로 늘었다. 이 사례는 합성 입력에 대한 순수 함수 검사다.


처분 가능 여부, Sell/Discard 배치, 고급 경고는 이름과 정확히 일치하는 `bag-cleanup-materials.json`의 661개 원본 정의에서 가져온다. 목록 존재는 현재 소유나 현재 조작 가능을 의미하지 않는다. unknown/unavailable은 판매로 대체하지 않는다. 잠금 해제·보관함 출고·유료 API·분해·일반 아이템 사용 경로를 추가하지 않는다.

검색 묶음은 field → bag.open → bag.items → 원본 계산 categories.reset wheel → bag.materials → bag.search → bag.reset → ENTER key-up 검색 포커스 → bag_text(name) → bag.apply다. wheel은 원본 ScrollSlot 길이/속도 계산으로 초기 offset을 왼쪽 경계로 보내며 600 이하 묶음과 120 개별 notch로 실제 포트에 전달한다.

검색 결과 경계에서만 정확한 품목명을 읽는다. Unicode NFC와 공백만 정리하며 `+`·등급·문장기호는 유지한다. OCR 점수가 0.6 미만인 글자가 있는 셀이나 여러 일치 셀은 선택하지 않는다. OCR box는 source 계산 cell의 이름 판정에만 쓰고 클릭 좌표는 생성하지 않는다.

판매 묶음은 정확한 item cell → source detail Sell → (원본 고급 등급이면 warning.confirm) → quantity → bag_text(quantity) → confirm → bag.close다. 버리기의 고급 경고는 quantity와 confirm 뒤에 나온다. 이 순서를 바꾸지 않는다. 정상 입력마다 새 OCR·CLI 승인을 끼우지 않는다. 공통 사용자 입력간격은 provider 값 또는 명시적으로 공급된 각 간격 배열을 사용하며 임의 기본값을 정하지 않는다.

원본: `bag_cleanup.py:16,101,159,233,321`, `bag_cleanup_plan.py:24,52,65`, `bag_cleanup_queue.py:41`, `source_bag_layouts.py`, `source_bag_scroll.py`. mini26의 `_finish`는 입력 경로 완료이며 실제 처분 수량 미확인이라고 명시한다. 이 원칙을 유지한다.

## 상태·효과·재개

`snapshot['bag_recovery']`에는 source/observed_at/character_id/session_id/frame_id/certainty='confirmed', stage(field/search_results/bag), bag_blocked(bool), policy, layout, input_interval_seconds가 필요하다. 검색 결과에는 search_name/category 및 search_results `(text, normalized_LTRB, score)`가 필요하다. `snapshot['inventory']`는 기존 get_items 정규화 envelope이며 complete 및 정확한 소유·시각을 요구한다.

layout은 source/source_bound=True와 현재 `CanvasContext`를 공급하거나 다음 JSON 필드를 공급한다. 모든 값은 현재 source reader가 확인해야 하며, 정적 constructor 기본값으로 confirmed를 만들지 않는다.

```json
{
  "source": "현재 캔버스 관측 출처",
  "source_bound": true,
  "viewport": {"width": 1920, "height": 1080},
  "safe_rect": [0, 0, 1920, 1080],
  "style": "pc", "pc_percent": 70,
  "orientation": "landscape", "apply_style_scale": true,
  "profile": {
    "reference_width": 1280, "reference_height": 720, "minimum_width": 1280,
    "provenance": "현재 profile 출처", "confirmed": true
  }
}
```

예시 숫자는 형식 설명이며 실행 상태가 아니다. safe_rect는 LTWH이다. 입력간격은 원본 사용자 설정/샘플에서 공급하며 `.1..60.25`를 수용한다. `input_intervals_seconds`를 공급하면 첫 입력 이후 각 간격에 그대로 적용한다.

결과는 action/waiting/recovered, proposal, next_receipt, prior_effect_confirmed, effect_evidence를 반환한다. receipt는 owner/policy hash/phase(search/dispose/close)/before_count/quantity/시각을 보존한다. 상위 실행기는 첫 입력 전 durable journal에 이를 저장한다. `dispose`의 unknown prefix는 판매/버리기를 재전송하지 않는다. 이후 현재 소유한 bag임을 확인하면 close만 가능하다. 완료 기록이나 수량 감소만으로 원래 목표를 복구 완료로 만들지 않는다. **더 새로운 현재 관측에서 bag_blocked=False일 때만 recovered**다. 원본이 요청하지 않은 처분 후 재고 확인 루프는 추가하지 않았다.

현재 provider는 다음과 같이 연결한다. 기존 custom `current_reader=callable`도 유지하지만 기본값은 concrete `CurrentBagReader`다.

```python
BagRecoveryProvider(
    policy,                       # 명시 사용자 정책, 없으면 처분 보류
    ocr=shared_ocr,                # 생략하면 기존 OfflineOCR 지연 생성
    receipt_reader=lambda: engine.data['recovery']['child_pending'],
    continuity_reader=current_uninterrupted_input_lease,
    input_interval_reader=current_user_input_interval,
)
```

recovery가 없을 때 receipt_reader는 None을 반환한다. continuity_reader는 같은 active 입력 lease가 창/프로세스/사용자 개입 없이 유지될 때만 True여야 한다. 단순히 같은 캐릭터 ID라는 이유로 True를 만들지 않는다. provider 호출은 `(window,node,image,frame,snapshot)`이다. 현재 `snapshot.current_context`의 glyph fit과 정확한 frame_observed_at을 이용해 source canvas를 얻는다. UI style 열거형은 픽셀로 추측하지 않으며 측정된 effective percent를 수식에 사용한다.

검색 receipt는 source layout·window_signature·초기 obstruction 증거를 함께 저장한다. 같은 창과 연속 소유권, sent_unconfirmed 검색 receipt가 있을 때만 가방의 source header와 현재 정확한 이름 cell을 읽어 search_results를 공급한다. 불명확한 검색 prefix는 처분을 시작하지 않는다. unknown 처분은 동일 소유권과 현재 가방 제목이 확인될 때 close tail만 허용한다. 메뉴가 HUD를 가리면 bag_blocked=None이며 새 현재 True로 위장하지 않는다. 이미 시작한 복구 child는 원래 obstruction 기록을 보존하고 메뉴 단계를 계속할 수 있다.

`read_bag_obstruction(image, rows, field_confirmed=True, ui_percent=70)`은 기존 본버전 방식으로 source glyph 일치(score≥.82), 원래 반경의 충분한 채도/명도, red 또는 amber 비율을 함께 요구한다. 현재 정확한 가방 경고는 True, source ring의 red는 True·amber는 False다. 경고 없음·초기 무게 경고·숨은 glyph·다른 화면비/UI 크기는 None이다. inventory/정원 counter로 상태를 만들지 않는다. ring은 16:9/UI70에 제한하며 다른 비율의 source 가방 좌표 계산 지원과 구분한다.

OCR를 재사용하려면 frame.ocr_rows에 기존 `OfflineOCR.read`의 `{text,score,box:[l,t,r,b]}` 목록을 넣고, frame.ocr_rows_frame_id를 같은 frame_id(또는 sha256)에 연결한다. 이 경우 provider는 OCR를 다시 호출하지 않는다. source 계산 ROI와 소유성을 여전히 검사한다. 스크린샷은 저장하지 않는다.

## 텍스트 입력 원본과 한계

`mini26/windows.py:760-824`의 `_replace_owned_text`는 이미 선택된 가방 필드에서 Ctrl+A → UTF-16 각 unit SendInput down/up을 사용한다. clipboard를 쓰지 않으며 bag_text는 중단된 prefix 뒤의 남은 문자를 재개하지 않는다. `timing.py`의 hold 삼각분포 `.055/.13/.085`를 그대로 사용한다. 새 포트에서는 focus를 가져오지 않고 매 단계 현재 guard를 재검사한다. 오류/부분입력은 눌렸을 수 있는 키의 release만 시도하고 unknown 영수증을 남긴다.

InputCommand의 `kind='bag_text'`, `target_id=text_scope='bag.search_name'|'bag.quantity'`, `text` 및 source evidence/expected rect가 필요하다. 검색명은 UTF-16 최대 50자, 수량은 양의 int32 숫자만 허용한다. 개행·제어문자·고립 surrogate·다른 field는 거부한다. ENTER는 target_id='bag.search_focus'에 한정한다. 이것은 채팅이나 임의 텍스트 입력 포트가 아니다.

## 검증 결과

가방 계획기·현재 픽셀 공급기 33개 합성 검사와 기존 IO 및 가방 입력 42개 모의 검사를 통과했다. 로그는 이 폴더 `unit-tests.log`와 `analysis/live-io/unit-tests.log`다. 실제 삭제·판매·버리기·텍스트 입력은 0회다. 실제 게임 화면에서의 현재 판독 및 효과 검증은 미완료다. 다른 비율/UI 크기의 capacity ring, 사용자 개입 뒤 잃은 메뉴 소유권, 정확하지 않은 검색 결과는 명시적 좁은 미지원/미확인 경계다.
