feat: add page map recognition and grouped task planning
This commit is contained in:
1 parent
386198e8e0
commit
f7628ab41e
76 files changed
+12437
-49
No files matched your search
@@ -0,0 +1,70 @@
|
||||
"""Read-only page recognition and route planning from the game page map."""
|
||||
import json
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
from .engine import Matcher
|
||||
|
||||
|
||||
class PageMap:
|
||||
def __init__(self, root):
|
||||
self.root = Path(root)
|
||||
self.data = json.loads((self.root / 'workflows/page_map.json').read_text(encoding='utf-8'))
|
||||
self.pages = {page['id']: page for page in self.data['pages']}
|
||||
self.matcher = Matcher(self.root / 'assets')
|
||||
|
||||
def identify(self, frame, previous_page=None):
|
||||
candidates = []
|
||||
for page in self.pages.values():
|
||||
for variant in page.get('recognition', []):
|
||||
scores = [self.matcher.score(frame, spec) for spec in variant]
|
||||
if scores and all(score >= spec.get('threshold', .86)
|
||||
for score, spec in zip(scores, variant)):
|
||||
candidates.append({'id': page['id'], 'name': page['name'],
|
||||
'kind': page['kind'], 'score': round(min(scores), 4)})
|
||||
break
|
||||
# A specific matching tab supersedes its matching container. Preserve
|
||||
# ambiguity between unrelated pages, even if their templates overlap.
|
||||
def ancestors(page_id):
|
||||
seen = set()
|
||||
current = self.pages[page_id].get('parent')
|
||||
while current in self.pages and current not in seen:
|
||||
seen.add(current)
|
||||
current = self.pages[current].get('parent')
|
||||
return seen
|
||||
|
||||
matched_parents = set().union(*(ancestors(item['id']) for item in candidates)) if candidates else set()
|
||||
specific = [item for item in candidates if item['id'] not in matched_parents]
|
||||
# Tabs inside an overlay retain the overlay's priority. The parent
|
||||
# of a generic reward still comes from history, never from its art.
|
||||
overlays = [item for item in specific if item['kind'] == 'overlay' or any(
|
||||
self.pages[parent]['kind'] == 'overlay' for parent in ancestors(item['id']))]
|
||||
active = overlays or specific
|
||||
# Shared popups may have identical pixels. Only a known navigation
|
||||
# origin can choose between them; absence of origin stays ambiguous.
|
||||
groups = {self.pages[item['id']].get('shared_visual_group') for item in active}
|
||||
if len(active) > 1 and len(groups) == 1 and None not in groups and previous_page in self.pages:
|
||||
contextual = [item for item in active if previous_page in ancestors(item['id'])]
|
||||
if len(contextual) == 1:
|
||||
active = contextual
|
||||
return {'status': 'identified' if len(active) == 1 else 'ambiguous' if active else 'unknown',
|
||||
'page': active[0] if len(active) == 1 else None, 'candidates': candidates}
|
||||
|
||||
def route(self, start, target):
|
||||
"""Plan verified navigation only; this method sends no input events."""
|
||||
if start not in self.pages or target not in self.pages:
|
||||
raise ValueError('未知页面 ID。')
|
||||
queue = deque([(start, [])])
|
||||
seen = {start}
|
||||
while queue:
|
||||
current, steps = queue.popleft()
|
||||
if current == target:
|
||||
return steps
|
||||
for edge in self.data['edges']:
|
||||
if edge['from'] != current or edge['status'] not in ('live_verified', 'workflow_verified'):
|
||||
continue
|
||||
if edge['to'] in seen:
|
||||
continue
|
||||
seen.add(edge['to'])
|
||||
queue.append((edge['to'], steps + [edge]))
|
||||
raise ValueError('没有已验证的导航路径;需要先补充页面与返回关系。')
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Configuration-only task audit and development plan. No desktop input."""
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
PAGE_ORDER = ('home', 'shop', 'territory', 'guild', 'any_primary')
|
||||
|
||||
|
||||
class TaskPlanner:
|
||||
def __init__(self, data, root):
|
||||
self.data = data
|
||||
self.root = Path(root)
|
||||
self.pages = {page['id']: page for page in data['pages']}
|
||||
|
||||
def ancestry(self, page_id):
|
||||
result = []
|
||||
while page_id is not None and page_id not in result:
|
||||
if page_id not in self.pages:
|
||||
break
|
||||
result.append(page_id)
|
||||
page_id = self.pages[page_id]['parent']
|
||||
return list(reversed(result))
|
||||
|
||||
def audit(self):
|
||||
issues = []
|
||||
for key in ('pages', 'edges', 'tasks', 'planned_tasks'):
|
||||
ids = [item['id'] for item in self.data[key]]
|
||||
issues.extend(f'{key}: duplicate {ident}' for ident, n in Counter(ids).items() if n > 1)
|
||||
task_ids = [task['id'] for task in self.data['tasks'] + self.data['planned_tasks']]
|
||||
issues.extend(f'tasks: duplicate across lists {ident}'
|
||||
for ident, n in Counter(task_ids).items() if n > 1)
|
||||
for page in self.data['pages']:
|
||||
parent = page['parent']
|
||||
if parent is not None and parent not in self.pages:
|
||||
issues.append(f"{page['id']}: missing parent {parent}")
|
||||
seen, current = set(), page['id']
|
||||
while current in self.pages:
|
||||
if current in seen:
|
||||
issues.append(f"{page['id']}: cyclic parent")
|
||||
break
|
||||
seen.add(current)
|
||||
current = self.pages[current]['parent']
|
||||
for variant in page.get('recognition', []):
|
||||
for spec in variant:
|
||||
if not (self.root / 'assets' / spec['template']).is_file():
|
||||
issues.append(f"{page['id']}: missing template {spec['template']}")
|
||||
for edge in self.data['edges']:
|
||||
if edge['from'] not in self.pages or edge['to'] not in self.pages:
|
||||
issues.append(f"{edge['id']}: missing endpoint")
|
||||
for task in self.data['tasks'] + self.data['planned_tasks']:
|
||||
if task.get('group_page') not in PAGE_ORDER:
|
||||
issues.append(f"{task['id']}: unknown group page {task.get('group_page')}")
|
||||
page_id = task.get('task_page')
|
||||
if page_id is None and task.get('group_page') == 'any_primary':
|
||||
continue
|
||||
if page_id not in self.pages:
|
||||
issues.append(f"{task['id']}: missing task page")
|
||||
for ident in self.ancestry(page_id):
|
||||
if self.pages[ident].get('automation', {}).get('enabled') is False:
|
||||
issues.append(f"{task['id']}: disabled ancestor {ident}")
|
||||
script = task.get('script')
|
||||
if script and not (self.root / script).is_file():
|
||||
issues.append(f"{task['id']}: missing script {script}")
|
||||
return {
|
||||
'valid': not issues, 'issues': issues,
|
||||
'counts': {key: len(self.data[key]) for key in ('pages', 'edges', 'tasks', 'planned_tasks')},
|
||||
'recognized_pages': sum(bool(page.get('recognition')) for page in self.pages.values()),
|
||||
'verified_edges': sum(edge['status'] in ('live_verified', 'workflow_verified')
|
||||
for edge in self.data['edges']),
|
||||
}
|
||||
|
||||
def hub(self, task):
|
||||
page = task.get('task_page')
|
||||
group = task['group_page']
|
||||
if group == 'any_primary':
|
||||
return 'any_primary'
|
||||
if page and page.startswith('offer.'):
|
||||
return 'home.offer'
|
||||
ancestors = self.ancestry(page)
|
||||
# Siblings share their first entrance below the primary page.
|
||||
return ancestors[1] if len(ancestors) > 1 and ancestors[0] == group else page
|
||||
|
||||
def family(self, task):
|
||||
if task.get('reusable_pattern'):
|
||||
return task['reusable_pattern']
|
||||
ident, page = task['id'], task.get('task_page') or ''
|
||||
if 'purchases' in ident or ident == 'shop_daily_coin_ticket':
|
||||
return 'priority_purchase'
|
||||
if ident in ('sweep', 'planet', 'relic_daily_sweep'):
|
||||
return 'sweep'
|
||||
if ident == 'wall_command_build':
|
||||
return 'explicit_command_build'
|
||||
if 'task' in page or ident in ('tasks', 'lizhu', 'pet_challenge_rewards'):
|
||||
return 'scrolling_reward_list'
|
||||
if ident in ('offer_diamond_fund_rewards', 'offer_relic_pass_rewards', 'offer_pet_pass_rewards'):
|
||||
return 'reward_icon_list'
|
||||
if 'store' in page or 'free' in ident or ident == 'shop_totem_free_rewards':
|
||||
return 'free_offer'
|
||||
return 'page_specific'
|
||||
|
||||
def plan(self, include_deferred=False, include_manual=False, group_page=None):
|
||||
rows, omitted = [], []
|
||||
existing = {task['id'] for task in self.data['tasks']}
|
||||
for task in self.data['tasks'] + self.data['planned_tasks']:
|
||||
if group_page and task['group_page'] != group_page:
|
||||
continue
|
||||
deferred = 'deferred' in task.get('status', '')
|
||||
manual = task['id'] in ('sweep', 'wall_command_build') or task.get('frequency') in ('on_demand', 'explicit_user_command')
|
||||
if deferred and not include_deferred or manual and not include_manual:
|
||||
omitted.append({'id': task['id'], 'reason': 'deferred' if deferred else 'explicit_command'})
|
||||
continue
|
||||
page_id = task.get('task_page')
|
||||
is_existing = task['id'] in existing
|
||||
blockers = []
|
||||
if not is_existing:
|
||||
blockers.append('任务执行器尚未实现')
|
||||
if not self.pages.get(page_id, {}).get('recognition'):
|
||||
blockers.append('任务页面尚无识别模板')
|
||||
if task.get('consumes_resources') is None:
|
||||
blockers.append('资源消耗需核实')
|
||||
if 'rules_incomplete' in task.get('status', ''):
|
||||
blockers.append('业务规则仍待确认')
|
||||
if deferred:
|
||||
blockers.append('用户要求延期完善')
|
||||
rows.append({
|
||||
'id': task['id'], 'name': task['name'], 'group_page': task['group_page'],
|
||||
'hub': self.hub(task), 'task_page': page_id, 'family': self.family(task),
|
||||
'implementation': 'existing_script' if is_existing else 'planned',
|
||||
'script': task.get('script'), 'frequency': task.get('frequency'),
|
||||
'consumes_resources': task.get('consumes_resources'),
|
||||
'requires_explicit_command': manual, 'blockers': blockers,
|
||||
'script_start': task.get('current_script_start'),
|
||||
'script_finish': task.get('current_script_finish'),
|
||||
'navigation_mode': 'legacy_script_owns_navigation' if is_existing else 'future_page_local_task',
|
||||
})
|
||||
rows.sort(key=lambda row: (PAGE_ORDER.index(row['group_page']), row['hub'] or '',
|
||||
row['implementation'] != 'existing_script', row['id']))
|
||||
visits = []
|
||||
for row in rows:
|
||||
if not visits or (visits[-1]['group_page'], visits[-1]['hub']) != (row['group_page'], row['hub']):
|
||||
visits.append({'group_page': row['group_page'], 'hub': row['hub'], 'tasks': []})
|
||||
visits[-1]['tasks'].append(row['id'])
|
||||
return {
|
||||
'mode': 'read_only_development_plan', 'tasks': rows, 'visit_groups': visits,
|
||||
'omitted': omitted,
|
||||
'note': '分组供后续调度迁移使用;现有脚本仍从首页启动并自行导航,不能直接送到子页面执行。此命令不会操作游戏。',
|
||||
}
|
||||
Reference in new issue
Block a user