From 6d3326da2a2e7ca9c041ca1add59ec83e18ac668 Mon Sep 17 00:00:00 2001 From: nichinichisou0609 <128159543+nichinichisou0609@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:23:07 +0800 Subject: [PATCH 1/2] Connect BoxLens review to version-bound Deck R3 recommendations --- .github/workflows/ci.yml | 2 +- MANIFEST.in | 1 + README.md | 10 ++ bdon_vision/__main__.py | 4 + bdon_vision/cli.py | 4 + bdon_vision/deck/__init__.py | 1 + bdon_vision/deck/adapter.py | 192 ++++++++++++++++++++++++++++++++ bdon_vision/deck/cli.py | 73 ++++++++++++ bdon_vision/deck/common.py | 62 +++++++++++ bdon_vision/deck/mock.py | 110 ++++++++++++++++++ bdon_vision/deck/recommend.py | 164 +++++++++++++++++++++++++++ bdon_vision/deck/server.py | 107 ++++++++++++++++++ bdon_vision/deck/visual_data.py | 38 +++++++ bdon_vision/deck/web.html | 64 +++++++++++ docs/DECK_PIPELINE.md | 89 +++++++++++++++ pyproject.toml | 5 +- tests/test_deck_inputs.py | 151 +++++++++++++++++++++++++ tests/test_deck_json.py | 50 +++++++++ 18 files changed, 1124 insertions(+), 3 deletions(-) create mode 100644 MANIFEST.in create mode 100644 bdon_vision/__main__.py create mode 100644 bdon_vision/deck/__init__.py create mode 100644 bdon_vision/deck/adapter.py create mode 100644 bdon_vision/deck/cli.py create mode 100644 bdon_vision/deck/common.py create mode 100644 bdon_vision/deck/mock.py create mode 100644 bdon_vision/deck/recommend.py create mode 100644 bdon_vision/deck/server.py create mode 100644 bdon_vision/deck/visual_data.py create mode 100644 bdon_vision/deck/web.html create mode 100644 docs/DECK_PIPELINE.md create mode 100644 tests/test_deck_inputs.py create mode 100644 tests/test_deck_json.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a13c06e..9c7a6d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: python-version: '3.11' cache: pip - run: python -m pip install -e '.[test]' build - - run: python -m unittest discover -s tests -p 'test_*.py' + - run: python -m pytest -q tests - run: python scripts/validate_contract.py scan-request schemas/examples/scan-request.json - run: python scripts/validate_contract.py scan-response schemas/examples/scan-response.json - run: python -m build diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..038aea3 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include docs/DECK_PIPELINE.md diff --git a/README.md b/README.md index c20eb17..29d186c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,16 @@ python boxvision.py scan --data DATA --threads 2 --output ./result screenshot-a. 输出 `observations.json`(每张截图的定位、来源指纹和字段)、`box.json`(合并结果)、带序号及来源指纹的标注图。同名输入文件不会覆盖彼此的标注图。成员与留影使用不同 ID 命名空间。 +## 接入组卡推荐 + +可选的正式 `deck-ui` 入口将截图识别、显式人工补齐和 [OurNotes Deck](https://github.com/empty-sekai/ournotes-deck) 的真实搜索连在一起: + +```powershell +python boxvision.py deck-ui --data DATA --deck-data deck-data.json --solver-bin ournotes-recommend.exe --workspace ./local/deck-ui --port 18790 --box-port 18793 +``` + +打开 `http://127.0.0.1:18790/`。需要与图库匹配的 DeckData 和支持 `ournotes-deck.recommendation-request/1` 的推荐程序;源码不附带游戏资源。未知字段不会默认填为等级/Rank 1,完整持有集合须由使用者声明。详细补齐契约、mock 复现、版本校验和目标语义见 [组卡输入管线](docs/DECK_PIPELINE.md)。 + ## 数据和模型 运行目录需要使用者自行准备 `catalog.json`、`index.npz`、卡面/canonical 图库、卡阶精灵,再将权重包中的 `encoder.onnx` 和 `fields.onnx` 放入该目录的 `models/`。公开附件只包含源码与权重,不包含游戏资源或开箱即用的图库。缺少多模式字段模型时拒绝启动,避免旧的纯等级模型把能力值当等级。`models/gallery.npz` 是可重建的缓存。 diff --git a/bdon_vision/__main__.py b/bdon_vision/__main__.py new file mode 100644 index 0000000..9ae637f --- /dev/null +++ b/bdon_vision/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() diff --git a/bdon_vision/cli.py b/bdon_vision/cli.py index 76093d0..8db6da6 100644 --- a/bdon_vision/cli.py +++ b/bdon_vision/cli.py @@ -19,7 +19,11 @@ def main(): p.add_argument('images',nargs='+',type=Path);p.add_argument('--output',type=Path,required=True) p.add_argument('--threads',type=int,default=2) p=sub.add_parser('serve');p.add_argument('--data',type=Path,required=True);p.add_argument('--port',type=int,default=18776) + from .deck.cli import add_parsers,run + add_parsers(sub) args=parser.parse_args() + if args.command.startswith("deck-"): + raise SystemExit(run(args)) if args.command=='prepare': prepare(args.data,allow_download=not args.offline) elif args.command=='scan': diff --git a/bdon_vision/deck/__init__.py b/bdon_vision/deck/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/bdon_vision/deck/__init__.py @@ -0,0 +1 @@ + diff --git a/bdon_vision/deck/adapter.py b/bdon_vision/deck/adapter.py new file mode 100644 index 0000000..3c277ee --- /dev/null +++ b/bdon_vision/deck/adapter.py @@ -0,0 +1,192 @@ +"""Strict evidence gate in front of the EXISTING Rust Roster, no scoring model.""" +from copy import deepcopy +from .common import read, digest, rows, load_deck + +FIELDS = {'member': {'level': 'level', 'awake': 'awake_count', 'rank': 'card_rank'}, + 'snap': {'level': 'level', 'rank': 'card_rank'}} +PLAYER = ['characterRanks', 'bandItems', 'vipRank', 'events', 'memory', + 'ownedMemberCardIds', 'ownedSupportCardIds'] + +class IncompleteInventory(ValueError): + def __init__(self, issues): + self.issues = issues + super().__init__('Inventory requires explicit review/completion: ' + '; '.join(i['path'] for i in issues)) + +def inventory(box, deck_path, *, mock=False, region=None, fixture_manifest=None,recognition_dataset=None): + if box.get('schema') != 'bdon-box/1': + raise ValueError('Unsupported BoxLens contract') + deck,deck_sha = load_deck(deck_path) + expected_region = deck['provenance']['region'] + if type(mock) is not bool: raise ValueError('Explicit mock flag must be boolean') + if region != expected_region: + raise ValueError('Explicit input region must match bound DeckData region') + return {'schema': 'ournotes.player-inventory/1', 'source': 'boxlens', 'mock': mock, + 'region': region, 'deckDataSha256': deck_sha, + 'masterVersion': deck['provenance']['master']['version'], + 'boxlensVersion': '0.1.0', 'boxlensCommit':'2ea25d8e2fdcbd3d1514b101a13b34d3e70d2ff1', + 'modelScope':'Offline native model 1.0.1-25; JP client 1.0.4 equivalence not inferred', 'box': deepcopy(box), + 'recognitionProvenance': box.get('recognition_provenance', []), + 'recognitionDataset':deepcopy(recognition_dataset),'fixtureManifest': fixture_manifest, 'coverage': 'observed_only'} + +def adapt(inv, completion, deck_path, minimum_confidence=.8): + if type(inv.get('mock')) is not bool: raise ValueError('Inventory mock declaration must be boolean') + if inv.get('schema') != 'ournotes.player-inventory/1': + raise ValueError('Unsupported inventory contract') + deck,deck_sha = load_deck(deck_path) + if inv.get('deckDataSha256') != deck_sha: + raise ValueError('DeckData version/hash mismatch') + if inv.get('region') != deck['provenance']['region']: raise ValueError('Inventory region binding mismatch') + if inv.get('masterVersion') != deck['provenance']['master']['version']: raise ValueError('Inventory masterVersion binding mismatch') + box = inv['box'] + if box.get('schema') != 'bdon-box/1': + raise ValueError('Unsupported BoxLens contract') + if not isinstance(minimum_confidence, (int,float)) or not 0 <= minimum_confidence <= 1: + raise ValueError('Invalid confidence threshold') + c = completion or {} + issues = [] + def issue(path, reason): issues.append({'path': path, 'reason': reason}) + def integer(value, path, low=1, high=None): + if type(value) is not int or value < low or (high is not None and value > high): + issue(path, 'invalid integer/range') + return False + return True + if c: + if c.get('schema') != 'ournotes.inventory-completion/1': + raise ValueError('Unsupported completion contract') + if c.get('deckDataSha256') != inv['deckDataSha256']: + raise ValueError('Completion DeckData hash mismatch') + if c.get('source') not in ('manual', 'mock-truth') or not c.get('evidence'): + raise ValueError('Completion requires explicit manual/mock evidence') + if c.get('source') == 'mock-truth' and (not inv['mock'] or c.get('mock') is not True): + raise ValueError('Mock truth cannot complete real-player inventory') + if c.get('ownershipComplete') is not True: issue('ownershipComplete', 'partial screenshots do not establish complete ownership') + if c.get('playerStateComplete') is not True: issue('playerStateComplete', 'explicit player state declaration required') + player = deepcopy(c.get('player', {})) + for f in PLAYER: + if f not in player or player[f] is None: issue('player.'+f, 'not recognized; explicit value required') + charids = {r['_id'] for r in rows(deck, 'MasterCharacter')} + ranks = player.get('characterRanks', {}) + if not isinstance(ranks, dict): + issue('player.characterRanks', 'expected complete map'); ranks = {} + for key in ranks: + if not key.isdigit() or str(int(key))!=key or int(key) not in charids: issue('player.characterRanks.'+key,'unknown/noncanonical character ID') + for char in charids: + if str(char) not in ranks: issue(f'player.characterRanks.{char}', 'would default to Rank1') + else: + integer(ranks[str(char)], f'player.characterRanks.{char}') + if ranks[str(char)] not in {r['_rank'] for r in rows(deck,'MasterCharacterRank')}: issue(f'player.characterRanks.{char}','unknown master character rank') + if 'vipRank' in player: + integer(player['vipRank'], 'player.vipRank') + if player['vipRank'] not in {1}|{r['_vipRank'] for r in rows(deck,'MasterVipRankBonus')}: issue('player.vipRank','unknown VIP rank') + if 'bandItems' in player: + items = {r['_id'] for r in rows(deck, 'MasterBandItem')} + if not isinstance(player['bandItems'],dict): issue('player.bandItems', 'expected explicit owned item map') + else: + levels = {(r['_bandItemId'], r['_level']) for r in rows(deck,'MasterBandItemLevel')} + for k, v in player['bandItems'].items(): + if type(v) is not int or not str(k).isdigit() or str(int(k))!=str(k) or int(k) not in items or (int(k),v) not in levels: + issue('player.bandItems.'+str(k), 'unknown item/level') + if 'events' in player: + events={r['_id'] for r in rows(deck,'MasterEvent')} + if not isinstance(player['events'],list) or any(type(i) is not int or i not in events for i in player['events']): + issue('player.events','expected explicit known event ID list') + memory=player.get('memory') + if isinstance(memory,dict): + for f in ['musicRanks','unlockedMembers','unlockedSupports']: + if f not in memory: issue('player.memory.'+f,'explicit collection required') + music_ranks=memory.get('musicRanks') + music_rows={r['_id']:r for r in rows(deck,'MasterMemoryMusic')} + if not isinstance(music_ranks,dict):issue('player.memory.musicRanks','explicit music-rank map required') + else: + for key,value in music_ranks.items(): + music=music_rows.get(int(key)) if str(key).isdigit() and str(int(key))==str(key) else None + if music is None or type(value) is not int or value not in {r['_scoreRank'] for r in rows(deck,'MasterMemoryMusicBonus') if r['_groupId']==music['_groupId']}: + issue('player.memory.musicRanks.'+str(key),'unknown memory music/rank in bound master') + elif 'memory' in player: issue('player.memory','explicit memory object required, even if empty') + observed={} + for row in box['cards']: + key=(row['kind'],row['id']) + if key in observed: raise ValueError('Duplicate native box identity') + observed[key]=row + if box['unique_count'] != len(observed) or box['duplicate_observations'] != box['observations'] - len(observed): + raise ValueError('Invalid BoxLens observation counts') + requested={} + for kind, key in [('member','members'),('snap','snaps')]: + for entry in c.get(key,[]): + ident=(kind,entry['id']) + if ident in requested: raise ValueError('Duplicate completion identity') + requested[ident]=entry + add=c.get('addMissingIdentities',[]) # explicit evidence-backed consent; never auto-add truth + exclude=c.get('excludeObservedIdentities',[]) + addkeys={(r['kind'],r['id']) for r in add} + exkeys={(r['kind'],r['id']) for r in exclude} + identities=(observed.keys() | addkeys) - exkeys + for ident in requested.keys()-identities-exkeys: issue(str(ident),'unobserved completion identity requires explicit addMissingIdentities') + corrections=[]; output={'player':player,'members':[],'snaps':[]} + for kind, id_ in sorted(identities): + path=f'{kind}:{id_}' + table='MasterMemberCard' if kind=='member' else 'MasterSupportCard' + master = next((r for r in rows(deck,table) if r['_id']==id_),None) + if master is None: issue(path,'identity not in bound regional master');continue + row=observed.get((kind,id_)); supplied=requested.get((kind,id_),{}) + owned={'id':id_} + for target, native in FIELDS[kind].items(): + field=(row or {}).get(native,{'value':None,'confidence':0}) + conflicts=(row or {}).get('conflicts',{}).get(native) + valid=field.get('value') is not None and field.get('confidence',0)>=minimum_confidence and not conflicts + if target in supplied: + val=supplied[target] + if not valid or val!=field['value']: + corrections.append({'kind':kind,'id':id_,'field':target,'previous':deepcopy(field), + 'previousConflicts':deepcopy(conflicts),'value':val,'source':c['source'],'evidence':c['evidence']}) + owned[target]=val + elif valid: owned[target]=field['value'] + else: issue(path+'.'+target,'conflicting/unknown/low-confidence; explicit completion required') + if kind=='member': + for target in ['liveSkillLevel','gekisouSkillLevel']: + if target not in supplied: issue(path+'.'+target,'not visible on list screenshot') + else: + owned[target]=supplied[target] + effect_table='MasterLiveSkillEffect' if target=='liveSkillLevel' else 'MasterGekisouSkillEffect' + card_skill='_liveSkillID' if target=='liveSkillLevel' else '_gekisouSkillID' + effect_skill='_liveSkillID' if target=='liveSkillLevel' else '_gekisouSkillID' + valid_levels={r['_level'] for r in rows(deck,effect_table) if r[effect_skill]==master[card_skill]} + if type(owned[target]) is not int or owned[target] not in valid_levels: + issue(path+'.'+target,'no matching effect level in bound master for card skill '+str(master[card_skill])) + for f,v in owned.items(): + if f!='id': integer(v,path+'.'+f) + group_fields={'level':('MasterMemberCardLevel' if kind=='member' else 'MasterSupportCardLevel','_memberCardLevelGroup' if kind=='member' else '_supportCardLevelGroup','_level'), + 'rank':('MasterMemberCardRank' if kind=='member' else 'MasterSupportCardRank','_memberCardRankGroup' if kind=='member' else '_supportCardRankGroup','_rank')} + if kind=='member':group_fields['awake']=('MasterMemberCardAwake','_memberCardAwakeGroup','_awakeCount') + for field,(table,group,value_column) in group_fields.items(): + if field in owned and (type(owned[field]) is not int or owned[field] not in {r[value_column] for r in rows(deck,table) if r['_group']==master[group]}): + issue(path+'.'+field,'no matching card group/value row in bound master') + if 'level' in owned and type(owned['level']) is int: + if kind=='member' and 'awake' in owned and type(owned['awake']) is int: + limits=[r['_limitLevel'] for r in rows(deck,'MasterMemberCardLevelLimit') if r['_rarity']==master['_rarity'] and r['_awakeCount']==owned['awake']] + if not limits or owned['level']>max(limits): issue(path+'.level','exceeds current awake cap') + if kind=='snap' and 'rank' in owned and type(owned['rank']) is int: + limits=[r['_limitLevel'] for r in rows(deck,'MasterSupportCardRank') if r['_group']==master['_supportCardRankGroup'] and r['_rank']==owned['rank']] + if not limits or owned['level']>max(limits): issue(path+'.level','exceeds current snap rank cap') + output['members' if kind=='member' else 'snaps'].append(owned) + for kind,f,collection in [('member','ownedMemberCardIds','members'),('snap','ownedSupportCardIds','snaps')]: + owned=player.get(f) + valid_ids={r['_id'] for r in rows(deck,'MasterMemberCard' if kind=='member' else 'MasterSupportCard')} + if not isinstance(owned,list) or any(type(i) is not int or i not in valid_ids for i in owned) or len(set(owned))!=len(owned): + issue('player.'+f,'explicit unique known owned set required; None means all-owned in Rust') + elif {r['id'] for r in output[collection]} != set(owned): + issue('player.'+f,'every declared owned card needs growth data or explicit observed exclusion review; pool must match declared owned set') + for f,ownedkey in [('unlockedMembers','ownedMemberCardIds'),('unlockedSupports','ownedSupportCardIds')]: + if isinstance(memory,dict) and f in memory: + values=memory[f] + if not isinstance(values,list) or any(type(v) is not int for v in values) or len(set(values))!=len(values) or not set(values).issubset(set(player.get(ownedkey) or [])): + issue('player.memory.'+f,'expected unique explicit owned unlock IDs') + memberrows={r['_id']:r for r in rows(deck,'MasterMemberCard')} + if len({memberrows[r['id']]['_characterID'] for r in output['members']}) < 5: issue('members','at least five different characters needed for a legal deck') + if issues: raise IncompleteInventory(issues) + return {'schema':'ournotes.roster-adaptation/1','mock':inv['mock'],'source':c['source'], + 'region':inv['region'],'deckDataSha256':inv['deckDataSha256'],'masterVersion':inv['masterVersion'], + 'recognitionDataset':deepcopy(inv.get('recognitionDataset')),'minimumConfidence':minimum_confidence,'complete':True,'roster':output,'corrections':corrections, + 'addedIdentities':add,'excludedIdentities':exclude,'originalBox':deepcopy(box), + 'assumptions':['Complete ownership and player state explicitly declared by completion evidence', + 'Mock truth completions are not OCR measurements']} diff --git a/bdon_vision/deck/cli.py b/bdon_vision/deck/cli.py new file mode 100644 index 0000000..ef2a65b --- /dev/null +++ b/bdon_vision/deck/cli.py @@ -0,0 +1,73 @@ +"""Optional BoxLens workflow using the existing ournotes-deck contracts.""" +import json +from pathlib import Path +from .common import read, write, digest, strict_json +from .adapter import inventory, adapt, IncompleteInventory + + +def add_parsers(sub): + def inputs(command, help): + parser=sub.add_parser(command,help=help) + parser.add_argument('--data',type=Path,required=True) + parser.add_argument('--deck-data',type=Path,required=True) + return parser + p=inputs('deck-ui','Local screenshot review and ournotes-deck recommendation UI') + p.add_argument('--solver-bin',type=Path,required=True) + p.add_argument('--workspace','--run',dest='workspace',type=Path,required=True) + p.add_argument('--port',type=int,default=18790) + p.add_argument('--box-port',type=int,default=18793) + p=inputs('deck-adapt','Review bdon-box/1 and export the existing Roster schema') + p.add_argument('--box',type=Path,required=True) + p.add_argument('--completion',type=Path) + p.add_argument('--region',required=True) + p.add_argument('--mock',action='store_true') + p.add_argument('--output',type=Path,required=True) + p=inputs('deck-mock','Generate declared mock screenshots using the original renderer') + p.add_argument('--output',type=Path,required=True) + p.add_argument('--members',type=int,default=7) + p.add_argument('--snaps',type=int,default=3) + p=sub.add_parser('deck-recommend',help='Run one exact request through the real solver') + p.add_argument('--deck-data',type=Path,required=True) + p.add_argument('--solver-bin',type=Path,required=True) + p.add_argument('--roster',type=Path,required=True) + p.add_argument('--request',type=Path,required=True) + p.add_argument('--output',type=Path,required=True) + + +def run(args): + if args.command=='deck-ui': + from .server import serve + if not args.solver_bin.is_file():raise ValueError('Solver executable not found') + serve(args.workspace,args.port,args.solver_bin.resolve(),args.box_port, + data=args.data,deck_data=args.deck_data) + elif args.command=='deck-mock': + from .visual_data import binding + from .mock import generate + binding(args.data,args.deck_data) + if args.output.exists():raise ValueError('Mock output must be a new directory') + manifest=generate(args.data,args.deck_data,args.output,args.members,args.snaps,newest_snaps=True) + print(json.dumps({'source':'mock','screenshots':len(manifest['screenshots'])})) + elif args.command=='deck-adapt': + from .visual_data import binding + inv=inventory(read(args.box),args.deck_data,mock=args.mock,region=args.region, + recognition_dataset=binding(args.data,args.deck_data)) + write(args.output/'inventory.json',inv) + try: + result=adapt(inv,read(args.completion) if args.completion else None,args.deck_data) + except IncompleteInventory as error: + write(args.output/'needs-review.json',{'complete':False,'issues':error.issues}) + print(str(error)) + return 2 + write(args.output/'adaptation.json',result) + write(args.output/'roster.json',result['roster']) + print(json.dumps({'complete':True,'mock':inv['mock'],'corrections':len(result['corrections'])})) + elif args.command=='deck-recommend': + from .recommend import recommend + raw=args.request.read_bytes().decode('utf-8') + request=strict_json(raw) + matrix={'deckDataSha256':digest(args.deck_data), + 'entries':[{'id':'user-request','request':request,'requestJson':raw}]} + result=recommend(args.solver_bin.resolve(),args.deck_data,args.roster,matrix,args.output) + print(json.dumps(result,ensure_ascii=False)) + return result['cases'][0].get('exitCode',2) + return 0 diff --git a/bdon_vision/deck/common.py b/bdon_vision/deck/common.py new file mode 100644 index 0000000..7373021 --- /dev/null +++ b/bdon_vision/deck/common.py @@ -0,0 +1,62 @@ +from pathlib import Path +import json, hashlib, math + +def strict_json(raw): + """Reject duplicate fields recursively before any typed solver boundary.""" + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate JSON field: {key}") + result[key] = value + return result + def finite(token): + value = float(token) + if not math.isfinite(value): + raise ValueError(f"Non-finite JSON number: {token}") + return value + def invalid_constant(token): + raise ValueError(f"Invalid JSON constant: {token}") + if isinstance(raw, bytes): + raw = raw.decode('utf-8-sig') + return json.loads(raw, object_pairs_hook=unique, parse_float=finite, + parse_constant=invalid_constant) + +def read(path): + return strict_json(Path(path).read_text(encoding='utf-8-sig')) + +def load_deck(path): + """Parse and fingerprint the SAME bytes, not self-reported provenance. + + The digest binds every master row and chart as well as the region metadata. + """ + raw = Path(path).read_bytes() + doc = strict_json(raw) + if doc.get("format") != "nnnotes.deck-data/1": + raise ValueError("Unsupported DeckData contract") + return doc, hashlib.sha256(raw).hexdigest() + +def write(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + +def rows(deck, name): + t = deck['master'][name] + return [dict(zip(t['columns'], row)) for row in t['rows']] + +def prepare_deck(source, target): + # Preserve EVERY numeric token from nnnotes' native exporter. Extra music-data + # fields are ignored by DeckData; never roundtrip master floats through Python. + raw = Path(source).read_text(encoding='utf-8-sig') + doc = strict_json(raw) + assert doc['format'] == 'nnnotes.music-data/1' + assert raw.count('"nnnotes.music-data/1"') == 1 + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(raw.replace('"nnnotes.music-data/1"', '"nnnotes.deck-data/1"', 1), encoding='utf-8') + return {'source': str(source), 'sourceSha256': digest(source), + 'targetSha256': digest(target), 'masterNumericTokens': 'unchanged', + 'charts': len(doc['charts']), 'masterVersion': doc['provenance']['master']['version']} diff --git a/bdon_vision/deck/mock.py b/bdon_vision/deck/mock.py new file mode 100644 index 0000000..e00ee3c --- /dev/null +++ b/bdon_vision/deck/mock.py @@ -0,0 +1,110 @@ +"""Mock UI reuses the original BoxLens native prefab renderer/composition.""" +from pathlib import Path +from collections import Counter +from .common import read, write, rows, digest + +def generate(data, deck_path, out, member_count=5, snap_count=1,newest_snaps=False): + import numpy as np + from PIL import Image, ImageDraw, ImageFilter + from bdon_vision.assets import native_module + out=Path(out); out.mkdir(parents=True,exist_ok=True) + deck=read(deck_path); catalog=read(Path(data)/'catalog.json')['cards'] + natives={k:read(Path(data)/('master/'+t+'.json'))['_allData'] for k,t in [('member','MasterMemberCard'),('snap','MasterSupportCard')]} + current={k:{r['_id']:r for r in rows(deck,t)} for k,t in [('member','MasterMemberCard'),('snap','MasterSupportCard')]} + old={k:{r['_id']:r for r in value} for k,value in natives.items()} + chosen=[]; chars=set(); parity=[] + for card in sorted([c for c in catalog if c['kind']=='member'],key=lambda c:c['id'],reverse=True): + row=current['member'].get(card['id']); before=old['member'].get(card['id']) + if not row or not before or row['_characterID'] in chars: continue + differences=[k for k,v in row.items() if before.get(k)!=v] + if differences: continue + chosen.append(card);chars.add(row['_characterID']);parity.append({'kind':'member','id':card['id'],'allScoringColumnsEqual':True}) + if len(chosen)==member_count: break + snap_source=sorted(catalog,key=lambda c:c['id'],reverse=True) if newest_snaps else catalog + snapcards=[c for c in snap_source if c['kind']=='snap' and c['id'] in current['snap'] and all(old['snap'][c['id']].get(k)==v for k,v in current['snap'][c['id']].items())][:snap_count] + chosen.extend(snapcards) + for c in snapcards: parity.append({'kind':'snap','id':c['id'],'allScoringColumnsEqual':True}) + if len(chosen)!=member_count+snap_count: raise ValueError('No semantically matching requested mock pool') + members=[];snaps=[] + for i,c in enumerate(chosen): + if c['kind']=='member': + awake=i%5+1;rank=i%5+1 + limits=[r['_limitLevel'] for r in rows(deck,'MasterMemberCardLevelLimit') if r['_rarity']==c['rarity'] and r['_awakeCount']==awake] + members.append({'id':c['id'],'level':min(max(limits),20+i*6),'awake':awake,'rank':rank,'liveSkillLevel':2,'gekisouSkillLevel':2}) + else: snaps.append({'id':c['id'],'level':24+3*(i-member_count),'rank':2+(i-member_count)%2}) + charids=[r['_id'] for r in rows(deck,'MasterCharacter')] + itemids=[r['_id'] for r in rows(deck,'MasterBandItem')] + player={'characterRanks':{str(i):10+i%8 for i in charids},'bandItems':{str(i):1 for i in itemids}, + 'vipRank':2,'events':[],'memory':{'musicRanks':{},'unlockedMembers':[members[0]['id']],'unlockedSupports':[snaps[0]['id']]}, + 'ownedMemberCardIds':[m['id'] for m in members],'ownedSupportCardIds':[s['id'] for s in snaps]} + completion={'schema':'ournotes.inventory-completion/1','source':'mock-truth','mock':True, + 'evidence':'Deterministic generated mock player; fixture-manifest.json and completion.json, no real-player assertion', + 'deckDataSha256':digest(deck_path),'ownershipComplete':True,'playerStateComplete':True, + 'player':player,'members':members,'snaps':snaps,'addMissingIdentities':[],'excludeObservedIdentities':[]} + write(out/'completion.json',completion) + write(out/'truth-roster.json',{'player':player,'members':members,'snaps':snaps}) + native=native_module(data); renderer=native.CardRenderer('_japanese') + (out/'cards').mkdir(exist_ok=True) + for card in chosen: + st=next(r for r in (members if card['kind']=='member' else snaps) if r['id']==card['id']) + renderer.render(native.CardState(asset_id=card['asset_id'],rarity=card['rarity'],card_type=card['card_type'],level=st['level'],rank=st['rank'],awake_count=st.get('awake',1),param='level'),card['kind'],scale=1.3).save(out/'cards'/f"{card['kind']}-{card['id']}.png") + states={('member',s['id']):s for s in members}|{('snap',s['id']):s for s in snaps} + lookup={(c['kind'],c['id']):c for c in chosen}; ids=[m['id'] for m in members] + split=3 if member_count==5 else 4 + plans=[('01-level-a.png','member',ids[:split],'level',False,False), + ('02-level-overlap.png','member',ids[split-1:],'level',False,False), + ('03-training.png','member',ids,'training',False,False), + ('04-hidden.png','member',ids,'hide',False,False), + ('05-snap.png','snap',[c['id'] for c in snapcards],'level',False,False), + ('06-conflict.png','member',[ids[0]],'level',True,False), + ('07-degraded-fields.png','member',ids[:2],'level',False,True)] + records=[] + for name,kind,cardids,mode,conflict,degraded in plans: + width,height=1280,720 + noise=np.random.default_rng(20261001).integers(130,220,(45,80,3),dtype=np.uint8) + scene=Image.fromarray(noise).resize((width,height),Image.Resampling.BICUBIC).filter(ImageFilter.GaussianBlur(12)).convert('RGBA') + ImageDraw.Draw(scene).text((28,28),'MOCK PLAYER | native card renderer | '+mode,fill=(40,40,60)) + scale=(.92 if member_count==5 else .80) if kind=='member' else (1.65 if snap_count==1 else 1.55) + # Three+ snaps use a complete three-column grid; never crop a declared visible card. + if kind=='snap' and snap_count>=3:scale=1.0 + columns=3 if kind=='snap' and snap_count>=3 else 5 + lw,lh=(224,294) if kind=='member' else (326,184) + truth=[] + for i,id_ in enumerate(cardids): + card=lookup[(kind,id_)];s=states[(kind,id_)] + lv=s['level']+(1 if conflict else 0) + state=native.CardState(asset_id=card['asset_id'],rarity=card['rarity'],card_type=card['card_type'], + level=lv,rank=s['rank'],awake_count=s.get('awake',1),param=mode) + tile=renderer.render(state,kind,scale=1.) + tile=tile.resize((round((lw+64)*scale),round((lh+64)*scale)),Image.Resampling.BILINEAR) + x=65+round((i%columns)*lw*scale*1.13); y=(165 if member_count==5 else 125)+round((i//columns)*lh*scale*1.10) + if not (0<=x and 0<=y and x+round(lw*scale)<=width and y+round(lh*scale)<=height): + raise ValueError(f'Mock visible card outside screenshot: {kind}#{id_}; fix layout, do not fabricate OCR ownership') + scene.alpha_composite(tile,(x-round(32*scale),y-round(32*scale))) + if degraded: + region=(x,y+round((lh-70)*scale),x+round(lw*scale),y+round(lh*scale)) + patch=scene.crop(region).filter(ImageFilter.GaussianBlur(14));scene.paste(patch,region[:2]) + truth.append({'kind':kind,'id':id_,'bbox':[x,y,round(lw*scale),round(lh*scale)], + 'level':lv if mode=='level' and not degraded else None, + 'awake_count':s.get('awake') if mode=='training' else None, + 'card_rank':s['rank'] if mode!='hide' and not degraded else None, + 'display_mode':mode,'latentState':s,'conflictingHistoricalObservation':conflict}) + path=out/name;scene.convert('RGB').save(path) + records.append({'file':name,'sha256':digest(path),'cards':truth,'profile':'degraded-fields' if degraded else 'clear', + 'source':'mock','locale':'ja','nativeComposition':'native-padding-preserved-v2'}) + # Duplicate bytes under another file name exercises cross-page/source evidence. + (out/'08-duplicate.png').write_bytes((out/'01-level-a.png').read_bytes()) + records.append({**records[0],'file':'08-duplicate.png','duplicateOf':'01-level-a.png'}) + from .visual_data import binding + manifest={'recognitionDataset':binding(data,deck_path),'schema':'ournotes.mock-fixture/1','mock':True,'source':'mock','seed':20261001, + 'region':deck['provenance']['region'],'masterVersion':deck['provenance']['master']['version'], + 'deckDataSha256':digest(deck_path),'recognitionCatalogSha256':digest(Path(data)/'catalog.json'), + 'galleryCoverage':dict(Counter(c['kind'] for c in catalog)),'deckCoverage':{'member':len(current['member']),'snap':len(current['snap'])}, + 'selectedIdentityParity':parity,'rendererSha256':digest(Path(data)/'native/render_game_components.py'), + 'generatorSource':'ournotes-boxlens/bdon_vision/assets.py::native_module and synthetic.py::generate native composition', + 'generatorSourceSha256':digest(Path(__file__).parents[1]/'synthetic.py'), + 'scope':'Mock card-list screenshots, generated backgrounds and player progress; original prefab renderer, approximate CPU SDF; not real game/player screenshots', + 'covers':['missing fields','degraded low-confidence fields','cross-page duplicate','conflict','level','awake','rank','skill levels via explicit truth','snap','character ranks','items','VIP','memory','explicit owned sets'], + 'screenshots':records,'truthFile':'completion.json','complete':True} + write(out/'fixture-manifest.json',manifest) + return manifest diff --git a/bdon_vision/deck/recommend.py b/bdon_vision/deck/recommend.py new file mode 100644 index 0000000..0e7e172 --- /dev/null +++ b/bdon_vision/deck/recommend.py @@ -0,0 +1,164 @@ +"""Invoke solver-owned recommendation binary and retain exact raw JSON.""" +import subprocess, time, re +from copy import deepcopy +from datetime import datetime, timedelta +from pathlib import Path +from .common import read, write, rows, digest, strict_json + +def write_request(entry, path): + """Keep uploaded legal numeric tokens intact; never collapse duplicate keys.""" + if 'requestJson' in entry: + raw = entry['requestJson'] + if not isinstance(raw, str) or strict_json(raw) != entry['request']: + raise ValueError('Original request JSON binding mismatch') + Path(path).write_bytes(raw.encode('utf-8')) + else: + write(path, entry['request']) + +def scene_matrix(deck_path): + d=read(deck_path) + shortest=min(d['charts'],key=lambda c:len(c['notes']['id'])) + sid=shortest['scoreId'] + base=next(r for r in rows(d,'MasterLiveMusic') if sid in [r.get('_easyID'),r.get('_normalID'),r.get('_hardID'),r.get('_expertID')]) + seed={'atoms':[[1,1],[-1,1],[42,1]],'provenance':'Declared illustrative mock finite native root law; not the real TickCount/server distribution'} + specs=[] + def add(id_, execution, scenario=None, metric=None, expected='supported'): + req={'format':'ournotes-deck.recommendation-request/1','execution':execution,'metric':metric or {'kind':'score'}, + 'constraints':{'noSnaps':False},'k':3,'strategy':{'kind':'exhaustive'}, + 'limits':{'timeLimitMs':30000,'maxCandidates':20000,'cacheEntries':2048}} + if scenario: req['scenario']=scenario + if execution['kind']=='live': req['seedLaw']=seed + specs.append({'id':id_,'request':req,'expected':expected}) + add('songless-power',{'kind':'power'},metric={'kind':'power'}) + for mode in ['free','mission','battle']: + sc={'kind':mode,'musicId':base['_id']} + add(mode+'-power',{'kind':'power'},sc,{'kind':'power'}) + if mode=='free':add(mode+'-skip',{'kind':'skip','scoreId':sid},sc) + add(mode+'-live',{'kind':'live','scoreId':sid,'gekisou':mode!='free','play':{'kind':'theoreticalBest'}},sc,expected='supported' if mode!='battle' else 'requires-external-network-confirmations') + if mode=='free':add('free-live-gekisou',{'kind':'live','scoreId':sid,'gekisou':True,'play':{'kind':'theoreticalBest'}},sc) + if mode!='free': + add('guard-'+mode+'-skip',{'kind':'skip','scoreId':sid},sc,expected='reject-native-choice') + add('guard-'+mode+'-gekisou-off',{'kind':'live','scoreId':sid,'gekisou':False,'play':{'kind':'theoreticalBest'}},sc,expected='reject-native-choice') + for mode,table in [('arena','MasterArenaMusic'),('challenge','MasterChallengeMusic')]: + candidates=rows(d,table) + if not candidates: + specs.append({'id':mode+'-unavailable','expected':'unavailable','reason':'No current regional '+table+' rows; no fabricated production scenario'}) + continue + r=candidates[0] + music=r['_liveMusicId'] + b=next(x for x in rows(d,'MasterLiveMusic') if x['_id']==music) + scores=[c for c in d['charts'] if c['scoreId'] in [b.get('_easyID'),b.get('_normalID'),b.get('_hardID'),b.get('_expertID')]] + s=min(scores,key=lambda c:len(c['notes']['id']))['scoreId'] + sc={'kind':mode,'musicId':r['_id']} + add(mode+'-power',{'kind':'power'},sc,{'kind':'power'}) + if mode=='challenge':add(mode+'-skip',{'kind':'skip','scoreId':s},sc) + add(mode+'-live',{'kind':'live','scoreId':s,'gekisou':mode=='arena','play':{'kind':'theoreticalBest'}},sc,expected='requires-external-network-confirmations' if mode=='arena' else 'supported') + if mode=='challenge':add('challenge-live-gekisou',{'kind':'live','scoreId':s,'gekisou':True,'play':{'kind':'theoreticalBest'}},sc) + add('free-probability',{'kind':'live','scoreId':sid,'gekisou':False,'play':{'kind':'theoreticalBest'}},{'kind':'free','musicId':base['_id']},{'kind':'scoreAtLeast','threshold':2400000}) + events=rows(d,'MasterEvent') + if events: + event=events[0] + start=datetime.strptime(event['_startAt'],'%Y/%m/%d %H:%M:%S') + end=datetime.strptime(event['_endAt'],'%Y/%m/%d %H:%M:%S') + def ticks(value): + delta=value-datetime(1,1,1) + return delta.days*864000000000+delta.seconds*10000000+delta.microseconds*10 + for original in list(specs): + req=original.get('request',{}) + execution=req.get('execution',{}).get('kind') + if execution not in ('skip','live') or original['id'].startswith('guard-') or req.get('metric',{}).get('kind')!='score':continue + mode=req['scenario']['kind'] + if mode=='challenge': + special=next(r for r in rows(d,'MasterChallengeMusic') if r['_id']==req['scenario']['musicId']) + if special['_eventId']!=event['_id']:continue + reward_rows=rows(d,'MasterChallengeLiveEventReward' if mode=='challenge' else 'MasterLiveEventReward') + if not reward_rows:continue + reward=reward_rows[0] + played=execution=='live' + clock={'execution':'played','savedStartJstTicks':ticks(start+timedelta(hours=1)),'serverNowJstTicks':ticks(start+timedelta(hours=1,minutes=5))} if played else {'execution':'skip','serverNowJstTicks':ticks(start+timedelta(hours=1))} + ctx={'powerSnapshot':{'eventIds':[event['_id']],'capturedJstTicks':ticks(start+timedelta(minutes=50))},'resultClock':clock, + 'eventPayoff':{'consumedCount':0,'localEvents':[{'eventId':event['_id'],'points':0,'challengePoints':500,'added':[]}], + 'eventWindows':[{'eventId':event['_id'],'startJstTicks':ticks(start),'endJstTicks':ticks(end)}], + 'selectedRewards':[{'eventId':event['_id'],'rewardId':reward['_id']}]}} + for metric in [{'kind':'clientEventPoints','eventId':event['_id']},{'kind':'conditionalClientEventItems','eventId':event['_id'],'resourceType':reward['_resourceType'],'resourceId':reward['_resourceId']}]: + cp=deepcopy(original);cp['id']+='-'+metric['kind'];cp['request']['metric']=metric;cp['request']['context']=deepcopy(ctx) + cp['contextSource']='Explicit mock event clock/counters/selected reward IDs; event windows and resource identity from bound master; no server selection inferred' + specs.append(cp) + for original in list(specs): + req=original.get('request',{}) + if req.get('execution',{}).get('kind')=='skip' and req.get('metric',{}).get('kind')=='score': + cp=deepcopy(original);cp['id']+='-probability';cp['request']['metric']={'kind':'scoreAtLeast','threshold':2400000};cp['request']['limits']['maxCandidates']=500000;specs.append(cp) + # Explicit hypothetical multiplayer inputs, separate from OCR and never inferred. + # The frame is packet ARRIVAL; the solver owns Finish8 scheduling and percent validation. + for original in list(specs): + req=original.get('request',{}) + if req.get('scenario',{}).get('kind')!='battle' or req.get('execution',{}).get('kind')!='live' or req['execution'].get('gekisou') is not True or original.get('expected')!='requires-external-network-confirmations':continue + cp=deepcopy(original);cp['id']='battle-conditional-mock-'+req['metric']['kind'];cp['expected']='supported-conditional-network' + missions=[base['_gekisouMission'+str(i)] for i in range(1,4)] + pattern=0 if 0 in missions else 1 if len(set(missions))==1 else 2 if len(set(missions))==3 else 3 + rank_rows=rows(d,'MasterLiveGekisouRankingScoreBonus') + ranks=[2,3,4] # Declared mock group ordinals; not predicted actual peer placement. + cp['request']['networkConfirmations']=[{'frame':5000,'range':i,'rank':rank,'percent':next(r['_scoreBonusPercent'] for r in rank_rows if r['_missionPattern']==pattern and r['_count']==i+1 and r['_rank']==rank)} for i,rank in enumerate(ranks)] + cp['contextSource']='Explicit hypothetical mock aggregate packet arrival frame5000 and group ordinals2/3/4; master percentages verified. Same-frame arrival does not mean same-frame application; immutable conditional ranks are not predicted peer scores.' + if 'context' in cp['request']: + cp['request']['context']['eventPayoff']['multiplayerResultPanel']={'localPlayerIndex':1,'localDisconnected':False,'otherPlayers':[{'finalScore':1000000,'disconnected':False},{'finalScore':900000,'disconnected':True}]} + cp['contextSource']+=' Mock result panel has explicit peer scores and one disconnected peer; no server-selected rewards inferred.' + specs.append(cp) + for original in list(specs): + req=original.get('request',{}) + if req.get('execution',{}).get('kind')!='live' or req.get('metric',{}).get('kind')!='score' or original.get('expected') not in ('supported','supported-conditional-network') or original['id']=='free-live':continue + cp=deepcopy(original);cp['id']+='-probability';cp['request']['metric']={'kind':'scoreAtLeast','threshold':2400000};specs.append(cp) + for entry in specs: + if 'request' in entry and entry['request']['execution']['kind']=='live': + entry['request']['strategy']={'kind':'candidate','powerSeeds':2,'proposals':600,'proposalSeed':20261001} + entry['request']['limits']={'timeLimitMs':10000,'maxCandidates':1000,'cacheEntries':1024} + return {'schema':'ournotes.integration-scene-matrix/1','owner':'BoxLens optional Deck recommendation workflow', + 'deckDataSha256':digest(deck_path),'entries':specs,'liveIsSeparate':True,'capabilitySource':'ournotes-deck.recommendation-request/1','randomScope':'Explicit finite native root law and theoretical play; unknown real native TickCount distribution','unsupportedScope':'Battle/Arena require explicitly provided aggregate peer confirmations. Conditional mock Battle cases are separate; current JP has no Arena master rows'} + +def recommend(binary, deck_path, roster_path, matrix, out): + out=Path(out);out.mkdir(parents=True,exist_ok=True) + sidecar=Path(roster_path).parent/'adaptation.json' + if not sidecar.exists():raise ValueError('Strict pipeline requires reviewed adaptation.json alongside Roster; use solver directly for independent raw-Roster tests') + review=read(sidecar) + # Preserve exact numeric tokens, and bind actual bytes used by the executable. + # External source files may change while a long search is running. + import hashlib,json + deck_bytes=Path(deck_path).read_bytes();roster_bytes=Path(roster_path).read_bytes() + deck_sha=hashlib.sha256(deck_bytes).hexdigest();roster_sha=hashlib.sha256(roster_bytes).hexdigest() + if review.get('complete') is not True or review.get('deckDataSha256')!=deck_sha or review.get('roster')!=strict_json(roster_bytes):raise ValueError('Reviewed roster binding mismatch') + result=[];start=time.perf_counter() + if matrix.get('deckDataSha256') not in (None,deck_sha):raise ValueError('Scene matrix DeckData hash mismatch') + entries=matrix.get('entries',matrix.get('cases',[])) + ids=[e['id'] for e in entries] + if len(set(ids))!=len(ids):raise ValueError('Duplicate matrix entry IDs would overwrite request evidence') + frozen_deck=out/'deck-data.input.json';frozen_roster=out/'roster.input.json' + if frozen_deck.exists() or frozen_roster.exists():raise ValueError('Recommendation output already has frozen inputs; use a new directory') + frozen_deck.write_bytes(deck_bytes);frozen_roster.write_bytes(roster_bytes) + write(out/'input-binding.json',{'schema':'ournotes.frozen-recommendation-input/1','deckDataSha256':deck_sha,'rosterSha256':roster_sha,'sourceDeckData':str(Path(deck_path).resolve()),'sourceRoster':str(Path(roster_path).resolve()),'actualBinaryDeckInput':str(frozen_deck.resolve()),'actualBinaryRosterInput':str(frozen_roster.resolve()),'numericTokens':'unchanged exact bytes'}) + for entry in entries: + id_=entry['id'] + if not isinstance(id_,str) or not re.fullmatch('[A-Za-z0-9_.-]{1,100}',id_):raise ValueError('Invalid matrix entry ID') + if 'request' not in entry: + result.append({'id':id_,'state':'Unavailable','reason':entry.get('reason'),'complete':False});continue + request=entry['request'];reqfile=out/(id_+'-request.json');write_request(entry,reqfile) + command=[str(binary),'--data',str(frozen_deck),'--roster',str(frozen_roster),'--request',str(reqfile)] + t=time.perf_counter() + try: + p=subprocess.run(command,capture_output=True,encoding='utf-8',errors='replace',timeout=min(600,max(180,(request.get('limits',{}).get('timeLimitMs') or 165000)/1000+15))) + (out/(id_+'-stdout.json')).write_text(p.stdout,encoding='utf-8') + (out/(id_+'-stderr.log')).write_text(p.stderr,encoding='utf-8') + try: raw=__import__('json').loads(p.stdout) + except ValueError: + try:raw=__import__('json').loads(p.stderr) + except ValueError:raw={'error':p.stderr.strip() or p.stdout.strip()} + result.append({'id':id_,'execution':request['execution'],'metric':request['metric'], + 'request':request,'requestJson':reqfile.read_bytes().decode('utf-8'),'requestFile':reqfile.name,'command':command,'exitCode':p.returncode, + 'elapsedMs':round((time.perf_counter()-t)*1000,2),'raw':raw,'rawJson':p.stdout or p.stderr}) + except subprocess.TimeoutExpired: + result.append({'id':id_,'state':'ProcessTimedOut','complete':False,'command':command, + 'elapsedMs':round((time.perf_counter()-t)*1000,2)}) + write(out/'recommendations.json',{'schema':'ournotes.pipeline-recommendations/1','mock':review['mock'],'source':review['source'],'region':review['region'],'masterVersion':review['masterVersion'], + 'deckDataSha256':deck_sha,'rosterSha256':roster_sha,'solverBinarySha256':digest(binary), + 'elapsedMs':round((time.perf_counter()-start)*1000,2),'cases':result}) + print('recommend:',id_,result[-1].get('exitCode',result[-1].get('state')),flush=True) + return read(out/'recommendations.json') diff --git a/bdon_vision/deck/server.py b/bdon_vision/deck/server.py new file mode 100644 index 0000000..00e7a21 --- /dev/null +++ b/bdon_vision/deck/server.py @@ -0,0 +1,107 @@ +"""Local integration UI; existing BoxLens HTTP server handles all recognition.""" +import base64, json, mimetypes, subprocess, sys, threading, time, uuid, socket +from pathlib import Path +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.request import urlopen, Request +from urllib.parse import unquote +from .common import read, write, digest, strict_json +from .adapter import inventory,adapt,IncompleteInventory + +def serve(run,port=18790,solver_bin=None,boxport=18793,*,data,deck_data): + data=Path(data).resolve();deck_data=Path(deck_data).resolve() + from .visual_data import binding + loaded_visual=binding(data,deck_data) + run=Path(run).resolve();run.mkdir(parents=True,exist_ok=True);sessions={'showcase':run};lock=threading.Lock() + for check_port in (port,boxport): + with socket.socket() as probe: + try:probe.bind(('127.0.0.1',check_port)) + except OSError as e:raise RuntimeError(f'Port {check_port} already occupied; choose isolated UI and BoxLens ports') from e + logs=run/'server-logs';logs.mkdir(exist_ok=True) + log=(logs/('boxlens-'+uuid.uuid4().hex+'.log')).open('w',encoding='utf-8') + worker=subprocess.Popen([sys.executable,'-m','bdon_vision','serve','--data',str(data),'--port',str(boxport)], + stdout=log,stderr=log,creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) + try: + for _ in range(100): + if worker.poll() is not None: raise RuntimeError(f'BoxLens HTTP worker exited; inspect {log.name}') + try: + with urlopen(f'http://127.0.0.1:{boxport}/health',timeout=.2) as r: + if json.load(r)['status']=='ok':break + except OSError: time.sleep(.1) + else:raise RuntimeError('BoxLens worker failed to become ready') + class Handler(BaseHTTPRequestHandler): + def send(self,obj,status=200,mime='application/json; charset=utf-8'): + body=obj if isinstance(obj,bytes) else json.dumps(obj,ensure_ascii=False).encode('utf-8') + self.send_response(status);self.send_header('Content-Type',mime);self.send_header('Content-Length',str(len(body))) + self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff');self.end_headers();self.wfile.write(body) + def do_GET(self): + try: + if self.path=='/':return self.send(Path(__file__).with_name('web.html').read_bytes(),mime='text/html; charset=utf-8') + if self.path=='/health':return self.send({'status':'ok','ports':[port,boxport],'run':str(run),'solver':str(solver_bin) if solver_bin else None,'deckDataSha256':loaded_visual['boundDeckDataSha256'],'visualManifestSha256':loaded_visual['datasetManifestSha256'],'visualIdentityCounts':loaded_visual['identityCounts']}) + if self.path=='/api/run': + d={"boxLensUrl":f"http://127.0.0.1:{boxport}/","loadedVisualData":loaded_visual} + for key,name in [('inventory','inventory.json'),('adaptation','adaptation.json'),('roster','roster.json'),('manifest','fixtures/fixture-manifest.json'),('scan','scan/box.json'),('baseline','baseline-live.stdout.json'),('metrics','e2e-report.json'),('matrix','scene-matrix.json'),('recommendations','recommendations-final/recommendations.json' if (run/'recommendations-final/recommendations.json').exists() else 'recommendations-r3/recommendations.json' if (run/'recommendations-r3/recommendations.json').exists() else 'recommendations-r2/recommendations.json' if (run/'recommendations-r2/recommendations.json').exists() else 'recommendations/recommendations.json')]: + if (run/name).exists():d[key]=read(run/name) + if 'matrix' not in d: + from .recommend import scene_matrix + d['matrix']=scene_matrix(deck_data) + d['hasMockTruth']=(run/'fixtures/completion.json').is_file() + return self.send(d) + if self.path.startswith('/files/'): + target=(run/unquote(self.path[7:])).resolve() + if run not in target.parents or not target.is_file():return self.send({'error':'not found'},404) + return self.send(target.read_bytes(),mime=mimetypes.guess_type(str(target))[0] or 'application/octet-stream') + return self.send({'error':'not found'},404) + except (ValueError,OSError) as e:self.send({'error':str(e)},400) + def do_POST(self): + try: + n=int(self.headers.get('Content-Length','0')) + if not 0OurNotes · Box to Deck + +
OurNotes / Box to Deck
本地识别与组卡
+
截图 → 识别 → 明确补齐 → 真实搜索

从你的卡片库存,找到可照摆的队伍

保留每条观察与冲突,用完整培养数据运行 OurNotes Deck。

测试玩家 · 非真实账号
+
+ +

截图输入与可选 mock 样本

复用原始原生卡片 UI 生成器,含跨页重复、隐藏字段、降质与等级冲突。

运行图片识别

图片送到本机现有 BoxLens。识别器只接收图片、图库与模型。

打开 BoxLens 原始校对界面 ↗

截图只观察可见卡片;技能等级、角色 Rank、道具、VIP、回忆和完整持有集合需要单独提供。
+

截图实际读到的字段

观察集合 · 保留 unknown
同一卡片的等级出现两个值时,合并值保留为未知,冲突证据完整保留。隐藏字段与降质拒识也保留未知。
查看原始 BoxLens JSON
逐图观察与模型来源 ↗
+

已明确补齐的 Roster

以下培养值来自识别或明确补充;技能与玩家状态的来源单独记录。

完整现有 Roster JSON

补充缺失信息

演示真值与识别过程分开,点击后才用于补齐。真实账号请上传声明来源的人工补充文件。

查看补充契约样例 ↗

只有必要字段、持有集合和版本绑定全部校验通过,才导出 Roster 并进入搜索。
+

推荐队伍

声明有限根种子分布下的 native Live;技能顺序由原生五槽洗牌决定。

目标指标
Complete 的最优性限定于请求中的卡池、约束、打法、有限 seedLaw 和模型范围。真实 TickCount / 服务器根种子总体分布未知。
各根种子的技能顺序、分数与精确目标值

场景矩阵与状态

Live、Skip 与综合力分别验收。不可用、未支持、超时和候选搜索状态按真实返回展示。

活动指标、外部排名时间线与打法可由请求文件明确提供;搜索会校验实际 master 与支持范围。

查看完整搜索结果与请求
+
+ diff --git a/docs/DECK_PIPELINE.md b/docs/DECK_PIPELINE.md new file mode 100644 index 0000000..1256bbd --- /dev/null +++ b/docs/DECK_PIPELINE.md @@ -0,0 +1,89 @@ +# 截图到组卡推荐 + +BoxLens 的可选 `deck-ui` 入口调用现有识别器和独立的 ournotes-deck 推荐程序。第三槽是队长;返回成员、对应留影、目标值、终止原因及完成/最优性状态。评分和搜索实现仍维护在 [ournotes-deck](https://github.com/empty-sekai/ournotes-deck)。 + +## 启动 + +安装本仓库后,准备自己的完整视觉 `DATA`、同区服 DeckData 和支持 v1 推荐接口的程序: + +```powershell +python boxvision.py deck-ui --data DATA --deck-data deck-data.json --solver-bin ournotes-recommend.exe --workspace ./local/deck-ui --port 18790 --box-port 18793 +``` + +浏览器打开 `http://127.0.0.1:18790/`。两项服务仅绑定回环地址,端口占用会拒绝启动,可改为其他独立端口。空 workspace 可直接上传自己的截图;也可使用明确生成的 mock workspace。使用 Ctrl+C 关闭时,界面会终止自己启动的识别子进程。 + +选择截图,声明是否为 mock,然后识别。所有冲突和低置信度保留未知;下一步上传人工补齐 JSON。输入完整且通过实际 master 校验后,才能选择场景运行推荐。切换场景、重扫或重新补齐会清除旧队伍,并拒绝迟到的旧响应;场景选择保持不变。刷新可载入 workspace 原有的样本,页面明确标注其来源。 + +## 数据身份与拥有集合 + +`visual-data-manifest.json` 支持 `region`、`masterVersion`、`deckDataSha256`、`resourceVersion`、`resourceHash`、`officialCatalogSha256`、`catalogSha256`、`indexSha256`、`encoderSha256`、`fieldModelSha256`、`gallerySha256` 及 `files` 相对路径哈希字典。出现的声明均与实际加载内容核对;DeckData 从同一字节快照解析和计算 SHA256。篡改 master 行但保留自报版本也会拒绝。每个推荐请求另冻结实际 DeckData/Roster 字节供程序读取,并保存这些真实输入的哈希与命令。 + +无 manifest 的旧目录会明确报告缺失/多余身份,不声称完整覆盖。图库身份覆盖只表示识别能力,不能扩大玩家持有集合。`coverage: observed_only` 在人工明确完成所有持有和培养信息前始终保留。不在绑定 master 的身份(例如旧快照留影 #70 不在当前 JP 数据中)不会被映射成其他卡片。 + +已独立验收的当前 JP 数据是 63 成员、64 留影(127 身份),master 1.0.0.300,客户端 1.0.4。索引 SHA256 `b54e63e94a68c61293bbd9685a8ccca47f5a0882ef9615de3c08c2fcda5e3522`。源码不附带这些游戏资源,也不推断其他区服使用相同数据。新身份只有实际观察或明确补充后才进入 Roster。 + +## 人工补齐契约 + +结构化库存是 `ournotes.player-inventory/1`,保留原始 `bdon-box/1`。补齐文件格式如下;示意值仅说明字段,不表示真实账号或当前 master 的合法培养范围: + +```json +{ + "schema": "ournotes.inventory-completion/1", + "source": "manual", + "evidence": "人工核对的培养页与完整持有集合", + "mock": false, + "deckDataSha256": "填写实际 DeckData SHA256", + "ownershipComplete": true, + "playerStateComplete": true, + "player": { + "characterRanks": {"1": 10}, + "bandItems": {}, + "vipRank": 1, + "events": [], + "memory": {"musicRanks": {}, "unlockedMembers": [], "unlockedSupports": []}, + "ownedMemberCardIds": [1], + "ownedSupportCardIds": [] + }, + "members": [{"id": 1, "level": 20, "awake": 1, "rank": 1, "liveSkillLevel": 2, "gekisouSkillLevel": 2}], + "snaps": [], + "addMissingIdentities": [], + "excludeObservedIdentities": [] +} +``` + +实际文件需提供所有 master 角色的 Rank;完整 owned 集合与导出卡池逐项一致,并至少有五个不同角色。技能等级、角色 Rank、道具、VIP、回忆、完整 owned 集合没有从列表截图取得,必须显式补充。卡片等级、卡阶、特训与技能上界从对应 master 的培养/技能行验证。`null`、缺必要字段或技能等级 999 均不会成为 complete。 + +未观察身份可在 `addMissingIdentities` 中显式填写 `kind`、`id`、`evidence`;误识别排除使用 `excludeObservedIdentities`,也需证据。人工纠错保留之前的值、冲突及来源。`mock-truth` 补齐仅适用于明确 mock 库存,不能补齐真实玩家输入。 + +## CLI 与 mock 复现 + +复用原始 native CardRenderer 生成七成员/三留影截图,单独输出真值,不向识别器传入真值: + +```powershell +python boxvision.py deck-mock --data DATA --deck-data deck-data.json --members 7 --snaps 3 --output ./local/mock/fixtures +python boxvision.py scan --data DATA --threads 1 --output ./local/mock/scan ./local/mock/fixtures/01-level-a.png ./local/mock/fixtures/02-level-overlap.png ./local/mock/fixtures/03-training.png ./local/mock/fixtures/04-hidden.png ./local/mock/fixtures/05-snap.png ./local/mock/fixtures/06-conflict.png ./local/mock/fixtures/07-degraded-fields.png ./local/mock/fixtures/08-duplicate.png +python boxvision.py deck-adapt --data DATA --deck-data deck-data.json --region jp --mock --box ./local/mock/scan/box.json --completion ./local/mock/fixtures/completion.json --output ./local/mock +python boxvision.py deck-recommend --deck-data deck-data.json --solver-bin ournotes-recommend.exe --roster ./local/mock/roster.json --request request.json --output ./local/recommend-new +python boxvision.py deck-ui --data DATA --deck-data deck-data.json --solver-bin ournotes-recommend.exe --workspace ./local/mock +``` + +每次生成和推荐使用新的输出目录,避免覆盖证据。三留影布局包含可见边界断言。推荐需要与 Roster 相邻的 `adaptation.json`,拒绝未经审阅的库存。请求文件遵循 solver 的 `ournotes-deck.recommendation-request/1`;可由界面选择预置场景或上传明确提供的请求。 + +上传 JSON 在浏览器使用严格 UTF-8 解码并保留原文本传入 HTTP;服务拒绝顶层/嵌套/转义后重复键、NaN、Infinity、非法 UTF-8 和畸形 JSON。合法大整数、小数 token 与 CRLF 原样保存至实际 CLI 请求,浏览器不会先 JSON.parse 再 stringify 将它们丢失。 + +## 目标与完成状态 + +综合力按综合力单位显示,不附加随机根种子条件。演出期望、阈值概率和活动指标显示各自语义与有限 seedLaw/声明打法/模型条件。Complete 表示程序确实穷尽了请求范围,最优性限定于该卡池、约束、外部输入、打法与声明根分布;未知现实根种子分布下的无条件最优不在结论中。Skip、综合力和 Live 分别展示,不能相互代替演出验收。 + +Mission/Battle/Arena 强制撃奏;Battle/Arena 多人结果需要显式外部排名确认到达时间线,不能自动视为 Solo 第一。活动收益使用声明的活动时钟和结果上下文,服务器所选奖励未知时不能声称现实收益保证。 + +独立 QA 的最终场景矩阵为 **45 PASS / 0 FAIL / 2 BLOCKED**,两项 blocked 因冻结 JP master 没有 Arena 数据;不表示 Arena 已在当前 JP 原生场景实测。synthetic Arena 模型检查单独保留。采用的 R3 推荐程序 SHA256 为 `d645f10a2e23efbdf1ef722f49892a26829871a711e9bc79179006e4afd3a1ef`;已有 48,960 物理队伍 Top3、槽位、留影和逐根结果独立对拍证据,不在此次接入重跑长穷举。数值模型边界是离线 native 1.0.1-25,未据此声称 JP 1.0.4 运行时完全等价。 + +## 仓库检查 + +```powershell +python -m pip install -e ".[test]" +python -m pytest -q tests +``` + +公共回归使用手写 synthetic master 和无游戏图像的 JSON,不分发原始 master、chart、APK、截图、卡面、账号或内部协作文件。原有 scan、serve 与训练路径继续可用。根目录 public-manifest.json 记录原 v0.1.0 发布基线;此 PR 的新输入管线由 git commit 与构建结果记录,未更新或重标旧发布/模型证据。 diff --git a/pyproject.toml b/pyproject.toml index 40bb8c1..3121e38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,18 +17,19 @@ dependencies = [ ] [project.optional-dependencies] -test = ["jsonschema>=4.18,<5"] +test = ["jsonschema>=4.18,<5", "pytest>=8,<10"] [project.scripts] ournotes-boxlens = "bdon_vision.cli:main" [tool.setuptools] -packages = ["bdon_vision"] +packages = ["bdon_vision", "bdon_vision.deck"] license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md", "licenses/*.txt"] include-package-data = false [tool.setuptools.package-data] bdon_vision = ["web.html", "web.js"] +"bdon_vision.deck" = ["web.html"] [tool.setuptools.exclude-package-data] "*" = ["__pycache__/*", "*.pyc", "*.pyo"] diff --git a/tests/test_deck_inputs.py b/tests/test_deck_inputs.py new file mode 100644 index 0000000..07cf2c4 --- /dev/null +++ b/tests/test_deck_inputs.py @@ -0,0 +1,151 @@ +"""Hand-authored synthetic master only; no game assets or extracted masterdata.""" +from copy import deepcopy +from pathlib import Path +import pytest +from bdon_vision.deck.common import read, write, digest +from bdon_vision.deck.adapter import inventory, adapt, IncompleteInventory +from bdon_vision.deck.visual_data import binding + + +def table(columns, records): + return {'columns':columns,'rows':records} + + +@pytest.fixture +def fixture(tmp_path): + master={ + 'MasterCharacter':table(['_id'],[[i] for i in range(1,8)]), + 'MasterCharacterRank':table(['_rank'],[[1],[10]]), + 'MasterVipRankBonus':table(['_vipRank'],[[2]]), + 'MasterBandItem':table(['_id'],[[100]]), + 'MasterBandItemLevel':table(['_bandItemId','_level'],[[100,1]]), + 'MasterEvent':table(['_id'],[]), + 'MasterMemoryMusic':table(['_id','_groupId'],[[1000,1]]), + 'MasterMemoryMusicBonus':table(['_groupId','_scoreRank'],[[1,1]]), + 'MasterMemberCard':table(['_id','_characterID','_rarity','_liveSkillID','_gekisouSkillID','_memberCardLevelGroup','_memberCardRankGroup','_memberCardAwakeGroup'],[[i,i,1,101,201,1,1,1] for i in range(1,8)]), + 'MasterSupportCard':table(['_id','_supportCardLevelGroup','_supportCardRankGroup'],[[1,1,1],[2,1,1],[3,1,1]]), + 'MasterLiveSkillEffect':table(['_liveSkillID','_level'],[[101,1],[101,2]]), + 'MasterGekisouSkillEffect':table(['_gekisouSkillID','_level'],[[201,1],[201,2]]), + 'MasterMemberCardLevel':table(['_group','_level'],[[1,1],[1,20],[1,21]]), + 'MasterMemberCardRank':table(['_group','_rank'],[[1,1],[1,2]]), + 'MasterMemberCardAwake':table(['_group','_awakeCount'],[[1,1],[1,2]]), + 'MasterMemberCardLevelLimit':table(['_rarity','_awakeCount','_limitLevel'],[[1,1,20],[1,2,21]]), + 'MasterSupportCardLevel':table(['_group','_level'],[[1,1],[1,20]]), + 'MasterSupportCardRank':table(['_group','_rank','_limitLevel'],[[1,1,20],[1,2,20]]) + } + path=tmp_path/'deck.json' + write(path,{'format':'nnnotes.deck-data/1','provenance':{'region':'test-region','master':{'version':'synthetic-1'}},'master':master}) + field=lambda value:{'value':value,'confidence':.99} + cards=[{'kind':'member','id':i,'level':field(20),'card_rank':field(1),'awake_count':field(1),'conflicts':{},'sources':[]} for i in range(1,6)] + cards.append({'kind':'snap','id':1,'level':field(20),'card_rank':field(1),'awake_count':field(None),'conflicts':{},'sources':[]}) + box={'schema':'bdon-box/1','cards':cards,'unique_count':6,'observations':6,'duplicate_observations':0} + inv=inventory(box,path,mock=True,region='test-region') + c={'schema':'ournotes.inventory-completion/1','source':'mock-truth','evidence':'Hand-authored synthetic fixture, no actual player','mock':True,'deckDataSha256':digest(path),'ownershipComplete':True,'playerStateComplete':True,'player':{'characterRanks':{str(i):10 for i in range(1,8)},'bandItems':{'100':1},'vipRank':2,'events':[],'memory':{'musicRanks':{},'unlockedMembers':[],'unlockedSupports':[]},'ownedMemberCardIds':list(range(1,6)),'ownedSupportCardIds':[1]},'members':[{'id':i,'level':20,'rank':1,'awake':1,'liveSkillLevel':2,'gekisouSkillLevel':2} for i in range(1,6)],'snaps':[{'id':1,'level':20,'rank':1}],'addMissingIdentities':[],'excludeObservedIdentities':[]} + data=tmp_path/'visual' + for name in ['index.npz','models/encoder.onnx','models/fields.onnx']: + p=data/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(b'synthetic-not-a-real-model') + write(data/'catalog.json',{'schema':'bdon-box-catalog/1','cards':[{'kind':'member','id':i} for i in range(1,8)]+[{'kind':'snap','id':i} for i in range(1,4)]}) + write(data/'visual-data-manifest.json',{'region':'test-region','masterVersion':'synthetic-1','deckDataSha256':digest(path),'files':{'index.npz':digest(data/'index.npz')}}) + return path,data,inv,c + + +def test_complete_explicit_state_preserves_raw_box(fixture): + path,_,inv,c=fixture;before=deepcopy(inv) + result=adapt(inv,c,path) + assert result['complete'] and result['roster']['player']==c['player'] + assert inv==before and result['originalBox']==inv['box'] + assert not result['addedIdentities'] + + +def test_missing_state_never_defaults_to_rank_one_or_all_owned(fixture): + path,_,inv,_=fixture + with pytest.raises(IncompleteInventory) as error:adapt(inv,None,path) + names={i['path'] for i in error.value.issues} + assert {'ownershipComplete','playerStateComplete','player.characterRanks.1','member:1.liveSkillLevel'}<=names + + +@pytest.mark.parametrize('name,value',[('region','forged-region'),('masterVersion','forged-master'),('deckDataSha256','0'*64)]) +def test_inventory_envelope_binding(fixture,name,value): + path,_,inv,c=fixture;inv[name]=value + with pytest.raises(ValueError,match='mismatch'):adapt(inv,c,path) + + +@pytest.mark.parametrize('field',['liveSkillLevel','gekisouSkillLevel','level','rank','awake']) +def test_growth_and_skill_bound_by_actual_master_rows(fixture,field): + path,_,inv,c=fixture;c['members'][0][field]=999 + with pytest.raises(IncompleteInventory) as error:adapt(inv,c,path) + assert any(i['path']==f'member:1.{field}' for i in error.value.issues) + + +def test_low_confidence_and_conflict_require_explicit_review(fixture): + path,_,inv,c=fixture + del c['members'][0]['level'];inv['box']['cards'][0]['level']['confidence']=.01 + inv['box']['cards'][0]['conflicts']={'level':{'20':['a'],'21':['b']}} + with pytest.raises(IncompleteInventory) as error:adapt(inv,c,path) + assert any(i['path']=='member:1.level' for i in error.value.issues) + c['members'][0]['level']=20 + result=adapt(inv,c,path) + assert result['corrections'][0]['previousConflicts']=={'20':['a'],'21':['b']} + assert result['corrections'][0]['source']=='mock-truth' + + +def test_mock_truth_cannot_complete_real_player(fixture): + path,_,inv,c=fixture;inv['mock']=False + with pytest.raises(ValueError,match='real-player'):adapt(inv,c,path) + + +@pytest.mark.parametrize('field',['ownedMemberCardIds','ownedSupportCardIds']) +def test_null_owned_set_cannot_mean_all_owned(fixture,field): + path,_,inv,c=fixture;c['player'][field]=None + with pytest.raises(IncompleteInventory):adapt(inv,c,path) + + +def test_full_catalog_does_not_expand_inventory_or_owned_sets(fixture): + path,data,inv,c=fixture;v=binding(data,path) + assert v['identityCounts']=={'member':7,'snap':3} and v['coversBoundMasterIdentities'] + observed=inventory(inv['box'],path,mock=True,region='test-region',recognition_dataset=v) + result=adapt(observed,c,path) + assert observed['coverage']=='observed_only' + assert len(result['roster']['members'])==5 and len(result['roster']['snaps'])==1 + assert result['roster']['player']['ownedMemberCardIds']==list(range(1,6)) + assert result['roster']['player']['ownedSupportCardIds']==[1] + + +def test_identity_outside_bound_master_is_not_remapped(fixture): + path,_,inv,c=fixture;c['addMissingIdentities']=[{'kind':'snap','id':70,'evidence':'explicit synthetic boundary'}] + with pytest.raises(IncompleteInventory) as error:adapt(inv,c,path) + assert any(i['path']=='snap:70' for i in error.value.issues) + + +@pytest.mark.parametrize('name,value',[('deckDataSha256','0'*64),('region','wrong'),('masterVersion','wrong'),('indexSha256','0'*64)]) +def test_visual_manifest_binding(fixture,name,value): + path,data,_,_=fixture;m=read(data/'visual-data-manifest.json');m[name]=value;write(data/'visual-data-manifest.json',m) + with pytest.raises(ValueError):binding(data,path) + + +def test_changed_actual_deck_bytes_with_same_reported_provenance_rejected_before_worker(fixture,monkeypatch): + path,data,_,_=fixture;changed=read(path);changed['master']['MasterMemberCard']['rows'][0][0]=12345;write(path,changed) + from bdon_vision.deck.server import serve + def unexpected(*args,**kwargs):raise AssertionError('OCR worker must not start before byte binding') + monkeypatch.setattr('bdon_vision.deck.server.subprocess.Popen',unexpected) + with pytest.raises(ValueError,match='DeckData hash mismatch'): + serve(path.parent/'ui',data=data,deck_data=path) + + +def test_changed_visual_file_rejected(fixture): + path,data,_,_=fixture;(data/'index.npz').write_bytes(b'mutated') + with pytest.raises(ValueError,match='hash mismatch'):binding(data,path) + + +def test_legacy_catalog_has_explicit_identity_gap(fixture): + path,data,_,_=fixture;(data/'visual-data-manifest.json').unlink() + cat=read(data/'catalog.json');cat['cards']=[r for r in cat['cards'] if r!= {'kind':'member','id':7}]+[{'kind':'snap','id':70}];write(data/'catalog.json',cat) + v=binding(data,path) + assert not v['coversBoundMasterIdentities'] + assert v['missingBoundIdentities']==[{'kind':'member','id':7}] + assert v['extraIdentities']==[{'kind':'snap','id':70}] + + +def test_manifest_path_escape_rejected(fixture): + path,data,_,_=fixture;m=read(data/'visual-data-manifest.json');m['files']['../deck.json']=digest(path);write(data/'visual-data-manifest.json',m) + with pytest.raises(ValueError,match='outside dataset'):binding(data,path) diff --git a/tests/test_deck_json.py b/tests/test_deck_json.py new file mode 100644 index 0000000..4600dfb --- /dev/null +++ b/tests/test_deck_json.py @@ -0,0 +1,50 @@ +"""Regressions for the independently reproduced HTTP duplicate-field bypass.""" +from pathlib import Path +import pytest +from bdon_vision.deck.common import read, strict_json, load_deck +from bdon_vision.deck.recommend import write_request + +@pytest.mark.parametrize('raw,key',[ + ('{"kind":"power","kind":"power"}','kind'), + ('{"execution":{"kind":"power","kind":"power"}}','kind'), + ('{"execution":{"play":{"deltaTimes":[0.016],"deltaTimes":[0.017]}}}','deltaTimes'), + ('{"cases":[{"request":{"kind":"power","kind":"live"}}]}','kind'), + ('{"kind":1,"k\\u0069nd":2}','kind'), +]) +def test_duplicate_fields_rejected_recursively(raw,key,tmp_path): + with pytest.raises(ValueError,match='Duplicate JSON field: '+key):strict_json(raw) + path=tmp_path/'matrix.json';path.write_text(raw,encoding='utf-8') + with pytest.raises(ValueError,match='Duplicate JSON field'):read(path) + +@pytest.mark.parametrize('token',['NaN','Infinity','-Infinity','1e999']) +def test_invalid_non_finite_numbers_rejected(token): + with pytest.raises(ValueError):strict_json('{"deltaTimes":['+token+']}') + +def test_uploaded_request_original_bytes_and_large_integer(tmp_path): + raw=' {"ticks":638999999999999999,"execution":{"play":{"deltaTimes":[0.016666668,1.6666668e-2,0.017000000]}}}\n' + parsed=strict_json(raw) + assert parsed['ticks']==638999999999999999 + path=tmp_path/'actual-binary-request.json' + write_request({'request':parsed,'requestJson':raw},path) + assert path.read_bytes()==raw.encode('utf-8') + +def test_raw_request_binding_or_duplicate_cannot_be_written(tmp_path): + path=tmp_path/'not-written.json' + with pytest.raises(ValueError,match='binding mismatch'): + write_request({'request':{'kind':'power'},'requestJson':'{"kind":"live"}'},path) + with pytest.raises(ValueError,match='Duplicate JSON field'): + write_request({'request':{'kind':'power'},'requestJson':'{"kind":"power","kind":"power"}'},path) + assert not path.exists() + +def test_actual_deck_import_rejects_duplicate_fields(tmp_path): + path=tmp_path/'deck.json' + path.write_text('{"format":"nnnotes.deck-data/1","master":{"x":1,"x":2}}',encoding='utf-8') + with pytest.raises(ValueError,match='Duplicate JSON field: x'):load_deck(path) + +@pytest.mark.parametrize('raw',[b'{"name":"\xff"}',b'{"name":"\xc3("}']) +def test_malformed_utf8_rejected(raw): + with pytest.raises(ValueError):strict_json(raw) + +@pytest.mark.parametrize('raw',['{"kind":','{"kind":"power"} trailing']) +def test_malformed_json_rejected(raw): + with pytest.raises(ValueError):strict_json(raw) From a0fb7b786eb9e2e91336c1ac1856d767266cb668 Mon Sep 17 00:00:00 2001 From: nichinichisou0609 <128159543+nichinichisou0609@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:23:42 +0800 Subject: [PATCH 2/2] Bind reviewed inventories across CLI and HTTP workspace reuse --- bdon_vision/deck/adapter.py | 11 +++ bdon_vision/deck/cli.py | 8 +- bdon_vision/deck/recommend.py | 4 +- bdon_vision/deck/review_state.py | 43 ++++++++++ bdon_vision/deck/server.py | 28 ++++--- docs/DECK_PIPELINE.md | 2 + tests/test_deck_inputs.py | 132 +++++++++++++++++++++++++++++++ 7 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 bdon_vision/deck/review_state.py diff --git a/bdon_vision/deck/adapter.py b/bdon_vision/deck/adapter.py index 3c277ee..0442363 100644 --- a/bdon_vision/deck/adapter.py +++ b/bdon_vision/deck/adapter.py @@ -118,6 +118,17 @@ def integer(value, path, low=1, high=None): requested[ident]=entry add=c.get('addMissingIdentities',[]) # explicit evidence-backed consent; never auto-add truth exclude=c.get('excludeObservedIdentities',[]) + for label, entries in [('addMissingIdentities',add),('excludeObservedIdentities',exclude)]: + if not isinstance(entries,list):raise ValueError(label+' must be an array') + seen=set() + for entry in entries: + if not isinstance(entry,dict) or entry.get('kind') not in FIELDS or type(entry.get('id')) is not int or entry['id']<1: + raise ValueError('Invalid '+label+' identity') + ident=(entry['kind'],entry['id']) + if ident in seen:raise ValueError('Duplicate '+label+' identity') + seen.add(ident) + if not isinstance(entry.get('evidence'),str) or not entry['evidence'].strip(): + raise ValueError(label+' requires nonempty per-identity evidence') addkeys={(r['kind'],r['id']) for r in add} exkeys={(r['kind'],r['id']) for r in exclude} identities=(observed.keys() | addkeys) - exkeys diff --git a/bdon_vision/deck/cli.py b/bdon_vision/deck/cli.py index ef2a65b..c049f6a 100644 --- a/bdon_vision/deck/cli.py +++ b/bdon_vision/deck/cli.py @@ -3,6 +3,7 @@ from pathlib import Path from .common import read, write, digest, strict_json from .adapter import inventory, adapt, IncompleteInventory +from .review_state import begin_review, finish_review def add_parsers(sub): @@ -49,17 +50,18 @@ def run(args): print(json.dumps({'source':'mock','screenshots':len(manifest['screenshots'])})) elif args.command=='deck-adapt': from .visual_data import binding + begin_review(args.output) inv=inventory(read(args.box),args.deck_data,mock=args.mock,region=args.region, recognition_dataset=binding(args.data,args.deck_data)) write(args.output/'inventory.json',inv) try: - result=adapt(inv,read(args.completion) if args.completion else None,args.deck_data) + completion=read(args.completion) if args.completion else None + result=adapt(inv,completion,args.deck_data) except IncompleteInventory as error: write(args.output/'needs-review.json',{'complete':False,'issues':error.issues}) print(str(error)) return 2 - write(args.output/'adaptation.json',result) - write(args.output/'roster.json',result['roster']) + result=finish_review(args.output,completion,result) print(json.dumps({'complete':True,'mock':inv['mock'],'corrections':len(result['corrections'])})) elif args.command=='deck-recommend': from .recommend import recommend diff --git a/bdon_vision/deck/recommend.py b/bdon_vision/deck/recommend.py index 0e7e172..88c8d3d 100644 --- a/bdon_vision/deck/recommend.py +++ b/bdon_vision/deck/recommend.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from pathlib import Path from .common import read, write, rows, digest, strict_json +from .review_state import load_review def write_request(entry, path): """Keep uploaded legal numeric tokens intact; never collapse duplicate keys.""" @@ -119,13 +120,12 @@ def recommend(binary, deck_path, roster_path, matrix, out): out=Path(out);out.mkdir(parents=True,exist_ok=True) sidecar=Path(roster_path).parent/'adaptation.json' if not sidecar.exists():raise ValueError('Strict pipeline requires reviewed adaptation.json alongside Roster; use solver directly for independent raw-Roster tests') - review=read(sidecar) # Preserve exact numeric tokens, and bind actual bytes used by the executable. # External source files may change while a long search is running. import hashlib,json deck_bytes=Path(deck_path).read_bytes();roster_bytes=Path(roster_path).read_bytes() deck_sha=hashlib.sha256(deck_bytes).hexdigest();roster_sha=hashlib.sha256(roster_bytes).hexdigest() - if review.get('complete') is not True or review.get('deckDataSha256')!=deck_sha or review.get('roster')!=strict_json(roster_bytes):raise ValueError('Reviewed roster binding mismatch') + review=load_review(Path(roster_path).parent,deck_sha=deck_sha,roster=strict_json(roster_bytes)) result=[];start=time.perf_counter() if matrix.get('deckDataSha256') not in (None,deck_sha):raise ValueError('Scene matrix DeckData hash mismatch') entries=matrix.get('entries',matrix.get('cases',[])) diff --git a/bdon_vision/deck/review_state.py b/bdon_vision/deck/review_state.py new file mode 100644 index 0000000..eacaba2 --- /dev/null +++ b/bdon_vision/deck/review_state.py @@ -0,0 +1,43 @@ +"""Current workspace review state shared by CLI, HTTP and recommendation.""" +from pathlib import Path +from .common import read, write, digest + + +def begin_review(directory): + # Keep old files as evidence, but revoke their authority before any mutation. + write(Path(directory) / 'needs-review.json', {'complete': False, 'state': 'pending'}) + + +def finish_review(directory, completion, adaptation): + directory = Path(directory) + adaptation = {**adaptation, 'inventorySha256': digest(directory / 'inventory.json')} + write(directory / 'reviewed-completion.json', completion) + write(directory / 'adaptation.json', adaptation) + write(directory / 'roster.json', adaptation['roster']) + # This is the commit point: partial writes remain unavailable to consumers. + (directory / 'needs-review.json').unlink() + return adaptation + + +def load_review(directory, *, deck_sha=None, roster=None): + directory = Path(directory) + if (directory / 'needs-review.json').exists(): + raise ValueError('Current inventory requires successful review before recommendation') + inventory_path = directory / 'inventory.json' + inv = read(inventory_path) + review = read(directory / 'adaptation.json') + if roster is None: + roster = read(directory / 'roster.json') + if review.get('complete') is not True or review.get('roster') != roster: + raise ValueError('Reviewed roster binding mismatch') + if (review.get('originalBox') != inv.get('box') or + any(review.get(key) != inv.get(key) for key in + ('mock', 'region', 'masterVersion', 'deckDataSha256'))): + raise ValueError('Reviewed inventory binding mismatch') + # Preserve valid historical R3 workspaces; new reviews also bind exact bytes. + if ('inventorySha256' in review and + review['inventorySha256'] != digest(inventory_path)): + raise ValueError('Reviewed inventory hash mismatch') + if deck_sha is not None and review.get('deckDataSha256') != deck_sha: + raise ValueError('Reviewed DeckData binding mismatch') + return review diff --git a/bdon_vision/deck/server.py b/bdon_vision/deck/server.py index 00e7a21..5b07a62 100644 --- a/bdon_vision/deck/server.py +++ b/bdon_vision/deck/server.py @@ -6,6 +6,7 @@ from urllib.parse import unquote from .common import read, write, digest, strict_json from .adapter import inventory,adapt,IncompleteInventory +from .review_state import begin_review,finish_review,load_review def serve(run,port=18790,solver_bin=None,boxport=18793,*,data,deck_data): data=Path(data).resolve();deck_data=Path(deck_data).resolve() @@ -38,14 +39,22 @@ def do_GET(self): if self.path=='/':return self.send(Path(__file__).with_name('web.html').read_bytes(),mime='text/html; charset=utf-8') if self.path=='/health':return self.send({'status':'ok','ports':[port,boxport],'run':str(run),'solver':str(solver_bin) if solver_bin else None,'deckDataSha256':loaded_visual['boundDeckDataSha256'],'visualManifestSha256':loaded_visual['datasetManifestSha256'],'visualIdentityCounts':loaded_visual['identityCounts']}) if self.path=='/api/run': - d={"boxLensUrl":f"http://127.0.0.1:{boxport}/","loadedVisualData":loaded_visual} - for key,name in [('inventory','inventory.json'),('adaptation','adaptation.json'),('roster','roster.json'),('manifest','fixtures/fixture-manifest.json'),('scan','scan/box.json'),('baseline','baseline-live.stdout.json'),('metrics','e2e-report.json'),('matrix','scene-matrix.json'),('recommendations','recommendations-final/recommendations.json' if (run/'recommendations-final/recommendations.json').exists() else 'recommendations-r3/recommendations.json' if (run/'recommendations-r3/recommendations.json').exists() else 'recommendations-r2/recommendations.json' if (run/'recommendations-r2/recommendations.json').exists() else 'recommendations/recommendations.json')]: - if (run/name).exists():d[key]=read(run/name) - if 'matrix' not in d: - from .recommend import scene_matrix - d['matrix']=scene_matrix(deck_data) - d['hasMockTruth']=(run/'fixtures/completion.json').is_file() - return self.send(d) + with lock: + d={"boxLensUrl":f"http://127.0.0.1:{boxport}/","loadedVisualData":loaded_visual} + for key,name in [('inventory','inventory.json'),('adaptation','adaptation.json'),('roster','roster.json'),('manifest','fixtures/fixture-manifest.json'),('scan','scan/box.json'),('baseline','baseline-live.stdout.json'),('metrics','e2e-report.json'),('matrix','scene-matrix.json'),('recommendations','recommendations-final/recommendations.json' if (run/'recommendations-final/recommendations.json').exists() else 'recommendations-r3/recommendations.json' if (run/'recommendations-r3/recommendations.json').exists() else 'recommendations-r2/recommendations.json' if (run/'recommendations-r2/recommendations.json').exists() else 'recommendations/recommendations.json')]: + if (run/name).exists():d[key]=read(run/name) + if 'matrix' not in d: + from .recommend import scene_matrix + d['matrix']=scene_matrix(deck_data) + d['hasMockTruth']=(run/'fixtures/completion.json').is_file() + try: + reviewed=load_review(run,deck_sha=loaded_visual['boundDeckDataSha256']) + if 'inventorySha256' in reviewed:d.pop('baseline',None) + if d.get('recommendations',{}).get('rosterSha256')!=digest(run/'roster.json'):d.pop('recommendations',None) + except (ValueError,OSError): + for key in ('adaptation','roster','baseline','recommendations'):d.pop(key,None) + d['reviewRequired']=True + return self.send(d) if self.path.startswith('/files/'): target=(run/unquote(self.path[7:])).resolve() if run not in target.parents or not target.is_file():return self.send({'error':'not found'},404) @@ -75,6 +84,7 @@ def do_POST(self): if sid not in sessions:raise ValueError('Unknown scan session') dest=sessions[sid] if self.path=='/api/adapt': + begin_review(dest) inv=read(dest/'inventory.json') if body.get('completionJson') is not None:body['completion']=strict_json(body['completionJson']) c=read(run/'fixtures/completion.json') if body.get('useMockTruth') else body.get('completion') @@ -83,7 +93,7 @@ def do_POST(self): known={(r['kind'],r['id']) for r in inv['box']['cards']} c['addMissingIdentities']=[{'kind':kind,'id':r['id'],'evidence':'Explicitly selected mock truth completion'} for kind,key in [('member','members'),('snap','snaps')] for r in c[key] if (kind,r['id']) not in known] adapted=adapt(inv,c,deck_data) - write(dest/'reviewed-completion.json',c);write(dest/'adaptation.json',adapted);write(dest/'roster.json',adapted['roster']) + adapted=finish_review(dest,c,adapted) return self.send({'inputBinding':{'sessionId':sid,'inventorySha256':digest(dest/'inventory.json'),'rosterSha256':digest(dest/'roster.json'),'clientInputVersion':body.get('clientInputVersion'),'clientRequestId':body.get('clientRequestId')},'sessionId':sid,'adaptation':adapted,'roster':adapted['roster']}) if self.path=='/api/recommend': if solver_bin is None or not Path(solver_bin).exists():return self.send({'error':'Final solver binary not configured'},503) diff --git a/docs/DECK_PIPELINE.md b/docs/DECK_PIPELINE.md index 1256bbd..64855fb 100644 --- a/docs/DECK_PIPELINE.md +++ b/docs/DECK_PIPELINE.md @@ -14,6 +14,8 @@ python boxvision.py deck-ui --data DATA --deck-data deck-data.json --solver-bin 选择截图,声明是否为 mock,然后识别。所有冲突和低置信度保留未知;下一步上传人工补齐 JSON。输入完整且通过实际 master 校验后,才能选择场景运行推荐。切换场景、重扫或重新补齐会清除旧队伍,并拒绝迟到的旧响应;场景选择保持不变。刷新可载入 workspace 原有的样本,页面明确标注其来源。 +CLI 和 HTTP 补齐共用工作区审阅状态。补齐开始即写入 `needs-review.json`,失败时旧文件仅保留为历史证据,推荐与页面均不将其视为当前完成数据;只有全部新审阅文件写入成功后才清除该标记。新审阅还记录精确 `inventorySha256`,旧 R3 审阅在没有待审标记、原始观察/区服/版本/mock 声明及 Roster 全部匹配时继续可用。存在待审标记的历史工作区需要重新完成补齐。重新补齐后,页面不会载入与当前 Roster 哈希不符的旧推荐或未绑定的新审阅前 baseline。 + ## 数据身份与拥有集合 `visual-data-manifest.json` 支持 `region`、`masterVersion`、`deckDataSha256`、`resourceVersion`、`resourceHash`、`officialCatalogSha256`、`catalogSha256`、`indexSha256`、`encoderSha256`、`fieldModelSha256`、`gallerySha256` 及 `files` 相对路径哈希字典。出现的声明均与实际加载内容核对;DeckData 从同一字节快照解析和计算 SHA256。篡改 master 行但保留自报版本也会拒绝。每个推荐请求另冻结实际 DeckData/Roster 字节供程序读取,并保存这些真实输入的哈希与命令。 diff --git a/tests/test_deck_inputs.py b/tests/test_deck_inputs.py index 07cf2c4..69788b6 100644 --- a/tests/test_deck_inputs.py +++ b/tests/test_deck_inputs.py @@ -149,3 +149,135 @@ def test_legacy_catalog_has_explicit_identity_gap(fixture): def test_manifest_path_escape_rejected(fixture): path,data,_,_=fixture;m=read(data/'visual-data-manifest.json');m['files']['../deck.json']=digest(path);write(data/'visual-data-manifest.json',m) with pytest.raises(ValueError,match='outside dataset'):binding(data,path) + +def test_cli_failed_review_revokes_old_roster_and_can_recover(fixture,monkeypatch): + from types import SimpleNamespace + from bdon_vision.deck.cli import run + from bdon_vision.deck.recommend import recommend + from bdon_vision.deck.review_state import load_review + import sys + path,data,inv,c=fixture + out=path.parent/'workspace';box=path.parent/'box.json';completion=path.parent/'completion.json' + write(box,inv['box']);write(completion,c) + args=SimpleNamespace(command='deck-adapt',data=data,deck_data=path,mock=True,region='test-region',box=box,completion=completion,output=out) + assert run(args)==0 + assert load_review(out)['inventorySha256']==digest(out/'inventory.json') + fresh=deepcopy(inv['box']);fresh['cards'][4]['id']=6;write(box,fresh) + args.completion=None + assert run(args)==2 + assert read(out/'inventory.json')['box']['cards'][4]['id']==6 + assert read(out/'roster.json')['members'][4]['id']==5 # retained evidence, not usable state + def forbidden(*args,**kwargs):raise AssertionError('solver must not run with failed current review') + monkeypatch.setattr('bdon_vision.deck.recommend.subprocess.run',forbidden) + matrix={'entries':[{'id':'audit','request':{'execution':{'kind':'power'},'metric':{'kind':'power'}}}]} + with pytest.raises(ValueError,match='requires successful review'): + recommend(sys.executable,path,out/'roster.json',matrix,path.parent/'rejected') + c['members'][4]['id']=6;c['player']['ownedMemberCardIds'][4]=6 + write(completion,c);args.completion=completion + assert run(args)==0 + assert not (out/'needs-review.json').exists() + assert load_review(out)['roster']['members'][4]['id']==6 + def solver(command,**kwargs): + assert read(command[command.index('--roster')+1])['members'][4]['id']==6 + return SimpleNamespace(stdout='{}',stderr='',returncode=0) + monkeypatch.setattr('bdon_vision.deck.recommend.subprocess.run',solver) + assert recommend(sys.executable,path,out/'roster.json',matrix,path.parent/'accepted')['cases'][0]['exitCode']==0 + + +def test_historical_review_requires_matching_current_inventory(fixture): + from bdon_vision.deck.review_state import load_review + path,_,inv,c=fixture;out=path.parent/'historical' + adapted=adapt(inv,c,path) + write(out/'inventory.json',inv);write(out/'adaptation.json',adapted);write(out/'roster.json',adapted['roster']) + assert load_review(out)==adapted # pre-fix R3 without inventorySha256 is still supported + inv['box']['cards'][4]['id']=6;write(out/'inventory.json',inv) + with pytest.raises(ValueError,match='inventory binding mismatch'):load_review(out) + + +def test_partial_review_write_never_commits_complete_state(fixture,monkeypatch): + from bdon_vision.deck import review_state + path,_,inv,c=fixture;out=path.parent/'partial' + write(out/'inventory.json',inv);review_state.begin_review(out) + original=review_state.write + def fail_roster(target,value): + if target.name=='roster.json':raise OSError('simulated disk failure') + original(target,value) + monkeypatch.setattr(review_state,'write',fail_roster) + with pytest.raises(OSError,match='disk failure'):review_state.finish_review(out,c,adapt(inv,c,path)) + with pytest.raises(ValueError,match='requires successful review'):review_state.load_review(out) + + +@pytest.mark.parametrize('label',['addMissingIdentities','excludeObservedIdentities']) +@pytest.mark.parametrize('evidence',[None,'',' ',{},123]) +def test_identity_review_requires_its_own_nonempty_evidence(fixture,label,evidence): + path,_,inv,c=fixture + entry={'kind':'member','id':6 if label=='addMissingIdentities' else 5} + if evidence is not None:entry['evidence']=evidence + c[label]=[entry] + if label=='addMissingIdentities': + c['members'].append({**c['members'][0],'id':6});c['player']['ownedMemberCardIds'].append(6) + else: + c['addMissingIdentities']=[{'kind':'member','id':6,'evidence':'explicit replacement'}] + c['members'][4]['id']=6;c['player']['ownedMemberCardIds'][4]=6 + assert c['evidence'] + with pytest.raises(ValueError,match='per-identity evidence'):adapt(inv,c,path) + c[label][0]['evidence']='Explicit manual identity review' + result=adapt(inv,c,path) + assert result['complete'] and result['addedIdentities']==c['addMissingIdentities'] + assert result['excludedIdentities']==c['excludeObservedIdentities'] + + +@pytest.mark.parametrize("state",["failed","legacy","updated"]) +def test_http_run_preserves_only_current_review_and_results(fixture,monkeypatch,state): + import io,threading + from types import SimpleNamespace + from bdon_vision.deck import server + from bdon_vision.deck.review_state import begin_review,finish_review + path,data,inv,c=fixture;out=path.parent/'http' + adapted=adapt(inv,c,path) + for name,value in [('inventory',inv),('adaptation',adapted),('roster',adapted['roster']),('scene-matrix',{'entries':[]}),('baseline-live.stdout',{'old':True})]:write(out/(name+'.json'),value) + write(out/'recommendations/recommendations.json',{'rosterSha256':digest(out/'roster.json'),'old':True}) + if state=='failed': + begin_review(out) + inv['box']['cards'][4]['id']=6;write(out/'inventory.json',inv) + elif state=='updated': + begin_review(out);c['members'][0]['level']=1 + finish_review(out,c,adapt(inv,c,path)) + lock=threading.Lock() + monkeypatch.setattr(server,'threading',SimpleNamespace(Lock=lambda:lock)) + monkeypatch.setattr(server.subprocess,'Popen',lambda *a,**k:SimpleNamespace(poll=lambda:None,terminate=lambda:None,wait=lambda **k:0)) + monkeypatch.setattr(server,'urlopen',lambda *a,**k:io.BytesIO(b'{"status":"ok"}')) + responses=[] + def http_server(address,handler_class): + def serve_forever(): + handler=object.__new__(handler_class);handler.path='/api/run' + handler.send=lambda body,*a,**k:responses.append(body) + started=threading.Event();finished=threading.Event() + def get(): + started.set() + try:handler.do_GET() + finally:finished.set() + lock.acquire() # simulate an in-flight POST workspace mutation + thread=threading.Thread(target=get) + try: + thread.start();assert started.wait(2) + assert not finished.wait(.1), 'GET must wait for the shared mutation lock' + finally: + lock.release();thread.join(2) + assert not thread.is_alive() and finished.is_set() + return SimpleNamespace(serve_forever=serve_forever) + monkeypatch.setattr(server,'ThreadingHTTPServer',http_server) + server.serve(out,port=0,boxport=0,data=data,deck_data=path) + response=responses[0] + if state=='failed': + assert response['inventory']['box']['cards'][4]['id']==6 + assert response['reviewRequired'] is True + assert not {'roster','adaptation','baseline','recommendations'} & response.keys() + elif state=='legacy': + assert response['roster']==adapted['roster'] + assert response['baseline']['old'] and response['recommendations']['old'] + assert not response.get('reviewRequired') + else: + assert response['roster']['members'][0]['level']==1 + assert 'baseline' not in response and 'recommendations' not in response + assert not response.get('reviewRequired')