#!/usr/bin/env python3 """Scaffold, validate and promote versioned Sanctification card work.""" from pathlib import Path import argparse, hashlib, json, os, re, shutil, sys, unicodedata import xml.etree.ElementTree as ET from PIL import Image import numpy as np REPO=next(p for p in Path(__file__).resolve().parents if (p/'docs/card-layer-pipeline.md').is_file()) RESOLUTIONS={'low':(500,700),'med':(1000,1400),'high':(2000,2800)} PRINTINGS=('normal','boundless','borderless','textless') STAGES=('art-review','assembly-review','approved') def digest(path):return hashlib.sha256(Path(path).read_bytes()).hexdigest() def read(path):return json.loads(Path(path).read_text()) def write(path,value):Path(path).write_text(json.dumps(value,indent=2)+'\n') def safe_name(value): if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*',value):raise ValueError(f'Use a lowercase stable slug: {value}') return value def safe_card_id(value): if not re.fullmatch(r'[A-Z]+-[0-9]{3,}',value):raise ValueError(f'Use the exact catalogue ID, e.g. BP-045: {value}') return value def title_slug(title): ascii_title=unicodedata.normalize('NFKD',title).encode('ascii','ignore').decode().lower() slug=re.sub(r'[^a-z0-9]+','-',ascii_title).strip('-') if not slug:raise ValueError('Title needs a readable ASCII folder slug') return safe_name(slug) def valid_folder_name(card_id,name): prefix=safe_card_id(card_id)+'-' if not name.startswith(prefix):raise ValueError(f'Folder must begin with {prefix}') safe_name(name[len(prefix):]);return name def profile_paths(root): return [path for path in root.glob('*/card.json') if not path.parent.is_symlink()] def resolve_folder_name(card_id): safe_card_id(card_id) roots=[REPO/'in-progress/cards',REPO/'artifacts/cards'] index=REPO/'in-progress/index.json' if index.is_file(): entries=[entry for entry in read(index)['cards'] if entry['cardId']==card_id] if len(entries)>1:raise ValueError(f'Duplicate catalogue ID in index: {card_id}') if entries and entries[0].get('folderName'): name=valid_folder_name(card_id,entries[0]['folderName']) for root in roots: path=root/name/'card.json' if path.is_file() and read(path)['cardId']==card_id:return name matches=[path for root in roots for path in profile_paths(root) if read(path)['cardId']==card_id] names={valid_folder_name(card_id,read(path)['folderName']) for path in matches} if len(names)!=1:raise ValueError(f'Expected one folder identity for {card_id}; found {len(names)}') return names.pop() def revision_candidates(name,revision): candidates=[REPO/'in-progress/cards'/name/'revisions'/revision, REPO/'artifacts/cards'/name] return [path for path in candidates if (path/'manifest.json').is_file() and read(path/'manifest.json')['revision']==revision] def historical_revision_paths(name,revision): base=REPO/'in-progress/cards'/name/'history' return [base/'revisions'/revision,base/f'accepted-{revision}'] def remap_content(directory,source,destination): path=directory/'card.json' content=read(path) # These are live build inputs; provenance and historical review reports stay intact. for key in ['art','previousNormal']: value=content.get(key) if value: target=REPO/value if target.is_relative_to(source):content[key]=str((destination/target.relative_to(source)).relative_to(REPO)) write(path,content) def rebase_linked_inputs(directory,source,destination): for path in directory.rglob('*'): if path.is_symlink(): target=path.resolve() if target.is_relative_to(source): mapped=destination/target.relative_to(source);path.unlink();path.symlink_to(os.path.relpath(mapped,path.parent)) elif path.suffix=='.svg' and path.is_file(): tree=ET.parse(path);changed=False for e in tree.getroot().iter(): for attr in ['href','{http://www.w3.org/1999/xlink}href']: value=e.get(attr) if value and not value.startswith(('data:','#')): target=(path.parent/value).resolve() if target.is_relative_to(source): e.set(attr,os.path.relpath(destination/target.relative_to(source),path.parent));changed=True if changed:tree.write(path,encoding='unicode') remap_content(directory,source,destination) def internalize_linked_inputs(directory,source): """Copy live inputs out of a package that is about to become compact history.""" source=source.resolve();replacements={};changed=False def is_source_file(path): path=path.resolve() return path.is_file() and path.is_relative_to(source) for path in sorted(directory.rglob('*')): if not path.is_symlink():continue target=path.resolve() if not is_source_file(target):continue path.unlink();shutil.copy2(target,path);replacements[target]=path;changed=True def local_copy(target): nonlocal changed target=target.resolve() if target in replacements:return replacements[target] preferred=directory/'source'/target.name if preferred.exists() and preferred.is_file() and digest(preferred)==digest(target): replacements[target]=preferred;return preferred if preferred.exists(): source_revision=read(source/'manifest.json')['revision'] preferred=directory/'source'/'retained'/source_revision/target.relative_to(source) preferred.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(target,preferred) replacements[target]=preferred;changed=True;return preferred content_path=directory/'card.json';content=read(content_path);content_changed=False for key in ['art','previousNormal']: value=content.get(key) if not value:continue target=(REPO/value).resolve() if is_source_file(target): content[key]=str(local_copy(target).relative_to(REPO));content_changed=True if content_changed:write(content_path,content) for path in sorted(directory.rglob('*.svg')): tree=ET.parse(path);svg_changed=False for e in tree.getroot().iter(): for attr in ['href','{http://www.w3.org/1999/xlink}href']: value=e.get(attr) if not value or value.startswith(('data:','#')):continue target=(path.parent/value).resolve() if is_source_file(target): e.set(attr,os.path.relpath(local_copy(target),path.parent));svg_changed=True if svg_changed: ET.register_namespace('','http://www.w3.org/2000/svg');ET.register_namespace('xlink','http://www.w3.org/1999/xlink') tree.write(path,encoding='unicode');changed=True return changed def compact_revision(directory,reason): """Retain text provenance and one low reference face per printing.""" manifest_path=directory/'manifest.json' if not manifest_path.is_file():raise ValueError(f'Cannot compact a revision without a manifest: {directory}') manifest=read(manifest_path);full_manifest=directory/'full-manifest.json' if full_manifest.exists():raise ValueError(f'Revision is already compact: {directory}') references=sorted((directory/'low').glob('*/card.png')) if not references: art=directory/'source/art-master.png' if art.is_file(): preview=directory/'review/art-reference.png';preview.parent.mkdir(parents=True,exist_ok=True) with Image.open(art) as image: image=image.convert('RGBA');image.thumbnail(RESOLUTIONS['low'],Image.Resampling.LANCZOS) image.save(preview,optimize=True) references=[preview] manifest_path.replace(full_manifest) for resolution in ['med','high']: path=directory/resolution if path.exists():shutil.rmtree(path) low=directory/'low' if low.exists(): for path in sorted(low.rglob('*'),reverse=True): if path.is_file() or path.is_symlink(): if not (path.name=='card.png' and path.parent.name in PRINTINGS):path.unlink() elif path.is_dir() and not any(path.iterdir()):path.rmdir() visual_suffixes={'.png','.jpg','.jpeg','.webp','.tif','.tiff','.gif','.bmp','.svg','.psd','.xcf'} for area in ['source','review']: root=directory/area if not root.exists():continue for path in sorted(root.rglob('*'),reverse=True): if (path.is_file() or path.is_symlink()) and path.suffix.lower() in visual_suffixes and path not in references:path.unlink() elif path.is_dir() and not any(path.iterdir()):path.rmdir() retained_references=[str(path.relative_to(directory)) for path in references if path.is_file()] record={'schemaVersion':1,'kind':'compact-card-revision','cardId':manifest['cardId'], 'folderName':manifest.get('folderName'),'revision':manifest['revision'],'originalStage':manifest['stage'], 'approval':manifest.get('approval'),'reason':reason,'originalManifest':'full-manifest.json', 'originalManifestSHA256':digest(full_manifest),'referenceResolution':'low','referenceImages':retained_references, 'retention':'Text provenance plus one 500 x 700 card face per available printing; full current acceptance lives in artifacts/cards.'} write(directory/'record.json',record);return record def add_history_entry(profile,revision,path,status): base=REPO/'in-progress/cards'/profile['folderName'];relative=str(path.relative_to(base)) if not any(entry.get('revision')==revision and entry.get('path')==relative for entry in profile.setdefault('history',[])): profile['history'].append({'revision':revision,'path':relative,'status':status}) def resolve_revision(card_id,revision): safe_card_id(card_id);safe_name(revision);name=resolve_folder_name(card_id) found=revision_candidates(name,revision) if len(found)!=1:raise ValueError(f'Expected one canonical revision for {card_id}/{revision}; found {len(found)}') directory=found[0];manifest=read(directory/'manifest.json') if manifest['cardId']!=card_id or manifest['revision']!=revision:raise ValueError('Revision identity does not match the request') return directory def scaffold(card_id,title,rarity,revision): safe_card_id(card_id);safe_name(revision) roots=[REPO/'in-progress/cards',REPO/'artifacts/cards'] existing=any(read(path)['cardId']==card_id for root in roots for path in profile_paths(root)) name=resolve_folder_name(card_id) if existing else f'{card_id}-{title_slug(title)}' base=roots[0]/name;target=base/'revisions'/revision if target.exists() or revision_candidates(name,revision) or any(path.exists() for path in historical_revision_paths(name,revision)): raise ValueError('Revision already exists; choose a new revision.') for folder in ['source','review']:(target/folder).mkdir(parents=True,exist_ok=True) for resolution in RESOLUTIONS: for printing in PRINTINGS:(target/resolution/printing).mkdir(parents=True) previous=next((read(root/name/'card.json') for root in roots if (root/name/'card.json').is_file()),None) profile=previous or {'schemaVersion':1,'cardId':card_id,'title':title,'rarity':rarity,'folderName':name,'history':[]} profile.update({'selectedRevision':revision,'stage':'art-review','approval':None});write(base/'card.json',profile) write(target/'card.json',{'cardId':card_id,'title':title,'name':title,'rarity':rarity,'folderName':name,'artApproval':None}) write(target/'manifest.json',{'schemaVersion':1,'cardId':card_id,'folderName':name,'revision':revision,'stage':'art-review','approval':None,'content':'card.json','files':{},'compatibilityAliases':[]}) refresh(target);update_index();return target def check_exports(directory,require_complete=True): exports={} for resolution,size in RESOLUTIONS.items(): files={} for printing in PRINTINGS: expected=['card.png','finish-mask.png']+(['text-mask.png'] if printing in ['normal','borderless'] else []) for filename in expected: if require_complete and not (directory/resolution/printing/filename).is_file():raise ValueError(f'Missing {resolution}/{printing}/{filename}') for path in sorted((directory/resolution/printing).glob('*')): if not path.is_file():continue record={'sha256':digest(path)} if path.suffix=='.png': image=Image.open(path).convert('RGBA') if image.size!=size:raise ValueError(f'Wrong dimensions: {path}') a=np.asarray(image) if np.any(a[:,:,3]!=255):raise ValueError(f'Non-opaque export: {path}') if path.name.endswith('mask.png') and (not np.array_equal(a[:,:,0],a[:,:,1]) or not np.array_equal(a[:,:,1],a[:,:,2])):raise ValueError(f'Non-grayscale data map: {path}') record['dimensions']=list(size) elif path.suffix=='.svg': for e in ET.parse(path).getroot().iter(): for attr in ['href','{http://www.w3.org/1999/xlink}href']: value=e.get(attr) if value and not value.startswith(('data:','#')) and not (path.parent/value).is_file():raise ValueError(f'Broken SVG image link: {path}: {value}') files[str(path.relative_to(directory/resolution))]=record exports[resolution]={'schemaVersion':1,'dimensions':list(size),'files':files} return exports def refresh(directory,allow_approved=False): manifest=read(directory/'manifest.json') if manifest['stage']=='approved' and not allow_approved:raise ValueError('Approved revision is frozen; create a new working revision.') export_records=check_exports(directory,manifest['stage']!='art-review') for label,record in export_records.items():write(directory/label/'manifest.json',record) # Imported legacy validation is a per-revision snapshot, not a mutable shared sidecar. if manifest.get('importedFrom')=='spikes/tier-layer-library-proof': proof=REPO/manifest['importedFrom'];source=proof/'output/validation.json';validation=read(source) entry=next(c for c in validation['cards'] if c['tier']==read(directory/'card.json')['tier']) write(directory/'review/build-validation.json',{'status':validation['status'],'importedFrom':str(source.relative_to(REPO)),'sourceReportSHA256':digest(source),'card':entry,'finishRecipe':validation['finishRecipe'],'movingLight':validation['visualReview']['movingLight']}) files={} for path in sorted(directory.rglob('*')): if path.is_file() and path.name!='manifest.json' and not path.is_relative_to(directory/'review') and '__pycache__' not in path.parts: files[str(path.relative_to(directory))]={'sha256':digest(path)} for label in RESOLUTIONS:files[f'{label}/manifest.json']={'sha256':digest(directory/label/'manifest.json')} manifest['files']=files card=read(directory/'card.json') if card.get('layerVersion'): sys.path.insert(0,str(REPO/'artifacts/layers'));from library import load_tier shared=load_tier(card['tier'],card['layerVersion']) manifest['sharedLayers']={'tier':card['tier'],'version':card['layerVersion'],'manifestSHA256':shared['manifestSHA256']} manifest['sharedFontManifestSHA256']=digest(REPO/'fonts/manifest.json') write(directory/'manifest.json',manifest) return manifest def validate(directory): manifest=read(directory/'manifest.json') if manifest['stage'] not in STAGES:raise ValueError('Unknown revision stage') for relative,record in manifest['files'].items(): if digest(directory/relative)!=record['sha256']:raise ValueError(f'Changed revision file: {relative}') if manifest.get('sharedLayers'): sys.path.insert(0,str(REPO/'artifacts/layers'));from library import load_tier shared=manifest['sharedLayers'];loaded=load_tier(shared['tier'],shared['version']) if loaded['manifestSHA256']!=shared['manifestSHA256']:raise ValueError('Pinned shared layer manifest changed') if digest(REPO/'fonts/manifest.json')!=manifest['sharedFontManifestSHA256']:raise ValueError('Pinned shared font manifest changed') exports=check_exports(directory,manifest['stage']!='art-review') for label,expected in exports.items(): if read(directory/label/'manifest.json')!=expected:raise ValueError(f'Stale resolution manifest: {label}') return manifest def relocate_revision(source,destination,aliases): """Retain linked inputs and editable SVG registration when changing directory depth.""" if destination.exists():raise ValueError('Approval destination already exists') links=[];svgs=[] for path in source.rglob('*'): if path.is_symlink():links.append((path.relative_to(source),path.resolve())) elif path.suffix=='.svg' and path.is_file(): tree=ET.parse(path);refs=[] for e in tree.getroot().iter(): for attr in ['href','{http://www.w3.org/1999/xlink}href']: value=e.get(attr) if value and not value.startswith(('data:','#')):refs.append((e,attr,(path.parent/value).resolve())) if refs:svgs.append((path.relative_to(source),tree,refs)) destination.parent.mkdir(parents=True,exist_ok=True);shutil.move(str(source),str(destination)) def moved(path):return destination/path.relative_to(source) if path.is_relative_to(source) else path for relative,target in links: slot=destination/relative;slot.unlink();slot.symlink_to(os.path.relpath(moved(target),slot.parent),target_is_directory=moved(target).is_dir()) for relative,tree,refs in svgs: path=destination/relative for e,attr,target in refs:e.set(attr,os.path.relpath(moved(target),path.parent)) ET.register_namespace('','http://www.w3.org/2000/svg');ET.register_namespace('xlink','http://www.w3.org/1999/xlink');tree.write(path,encoding='unicode') remap_content(destination,source,destination) for alias in aliases: slot=REPO/alias['alias'];target=destination/alias['target'] if not slot.is_symlink():raise ValueError(f'Expected compatibility symlink: {slot}') slot.unlink();slot.symlink_to(os.path.relpath(target,slot.parent)) return destination def approve(card_id,revision,note): if not note.strip():raise ValueError('Record the user’s explicit approval wording') name=resolve_folder_name(card_id);base=REPO/'in-progress/cards'/name profile_path=base/'card.json' if read(profile_path)['selectedRevision']!=revision:raise ValueError('Select this exact revision in the card profile before approving it') source=resolve_revision(card_id,revision);manifest=validate(source) if manifest['stage']!='assembly-review':raise ValueError('Only an assembly-review revision can be approved') aliases=manifest.get('compatibilityAliases',[]) for alias in aliases: if not (REPO/alias['alias']).is_symlink():raise ValueError('Compatibility alias no longer exists') siblings=sorted(path for path in (base/'revisions').glob('*') if path.is_dir() and path!=source) sibling_archives=[base/'history/revisions'/path.name for path in siblings] if any(path.exists() for path in sibling_archives):raise ValueError('Superseded revision history destination already exists') target=REPO/'artifacts/cards'/name;previous=None;archive=None if target.exists(): previous=validate(target) if previous['stage']!='approved':raise ValueError('Existing artifact is not an approved card') archive=base/'history'/f"accepted-{previous['revision']}" if archive.exists():raise ValueError('Previous acceptance archive already exists') changed=False for dependency in siblings+([target] if target.exists() else []): changed=internalize_linked_inputs(source,dependency) or changed if changed: manifest=refresh(source);validate(source) if target.exists(): relocate_revision(target,archive,previous.get('compatibilityAliases',[])) refresh(archive,allow_approved=True);compact_revision(archive,'replaced by a newer accepted revision') target=relocate_revision(source,target,aliases) manifest['stage']='approved';manifest['approval']={'by':'user','note':note};write(target/'manifest.json',manifest) profile=read(profile_path);profile.update({'selectedRevision':revision,'stage':'approved','approval':manifest['approval']}) if archive is not None:add_history_entry(profile,previous['revision'],archive,'superseded-acceptance-record') for sibling,destination in zip(siblings,sibling_archives): destination.parent.mkdir(parents=True,exist_ok=True);shutil.move(str(sibling),str(destination)) compact_revision(destination,f'superseded when {revision} was accepted') add_history_entry(profile,read(destination/'record.json')['revision'],destination,'superseded-revision-record') write(profile_path,profile) content=read(target/'card.json') for key in ['schemaVersion','selectedRevision','stage','approval','rarity']: if key in profile:content[key]=profile[key] write(target/'card.json',content) refresh(target,allow_approved=True);update_index();return target def compact_card_history(card_id): """Apply compact retention to history for a card that is already approved.""" name=resolve_folder_name(card_id);base=REPO/'in-progress/cards'/name;profile_path=base/'card.json' profile=read(profile_path);target=REPO/'artifacts/cards'/name manifest=validate(target) if manifest['stage']!='approved' or profile.get('stage')!='approved':raise ValueError('History cleanup requires an approved card') accepted=sorted(path for path in (base/'history').glob('accepted-*') if (path/'manifest.json').is_file()) working=sorted(path for path in (base/'revisions').glob('*') if (path/'manifest.json').is_file()) destinations=[base/'history/revisions'/path.name for path in working] if any(path.exists() for path in destinations):raise ValueError('Superseded revision history destination already exists') changed=False for dependency in accepted+working:changed=internalize_linked_inputs(target,dependency) or changed if changed:refresh(target,allow_approved=True) validate(target) records=[] for archive in accepted: record=compact_revision(archive,'replaced by the current accepted revision');records.append(record) add_history_entry(profile,record['revision'],archive,'superseded-acceptance-record') for source,destination in zip(working,destinations): destination.parent.mkdir(parents=True,exist_ok=True);shutil.move(str(source),str(destination)) record=compact_revision(destination,f"superseded when {manifest['revision']} was accepted");records.append(record) add_history_entry(profile,record['revision'],destination,'superseded-revision-record') write(profile_path,profile);update_index() return {'cardId':card_id,'acceptedRevision':manifest['revision'],'compacted':[record['revision'] for record in records]} def update_index(): profiles={} # Working selection takes precedence when an older approved release also exists. for root in [REPO/'artifacts/cards',REPO/'in-progress/cards']: seen=set() for path in profile_paths(root): profile=read(path);card_id=safe_card_id(profile['cardId']);name=valid_folder_name(card_id,profile['folderName']) if card_id in seen:raise ValueError(f'Duplicate catalogue ID: {card_id}') if path.parent.name!=name:raise ValueError('Profile folderName differs from its directory') seen.add(card_id) if card_id in profiles and profiles[card_id]['folderName']!=name:raise ValueError('Working and approved folder identities disagree') profiles[card_id]=profile cards=[] for card_id,profile in sorted(profiles.items()): name=profile['folderName'];revision=safe_name(profile['selectedRevision']) found=revision_candidates(name,revision) if len(found)!=1:raise ValueError(f'Expected one selected revision for {card_id}') directory=found[0];manifest=read(directory/'manifest.json') if manifest['cardId']!=card_id:raise ValueError('Selected revision identity mismatch') cards.append({'cardId':card_id,'title':profile['title'],'folderName':name,'rarity':profile['rarity'],'selectedRevision':revision,'stage':manifest['stage'],'path':str(directory.relative_to(REPO))}) write(REPO/'in-progress/index.json',{'schemaVersion':2,'cards':cards});return cards if __name__=='__main__': parser=argparse.ArgumentParser(description=__doc__);sub=parser.add_subparsers(dest='command',required=True) for command in ['new','refresh','validate','approve']: p=sub.add_parser(command);p.add_argument('--card',required=True);p.add_argument('--revision',required=True) if command=='new':p.add_argument('--title',required=True);p.add_argument('--rarity',choices=['Common','Uncommon','Rare','Extraordinary','Legendary'],required=True) if command=='approve':p.add_argument('--approval-note',required=True) compact_parser=sub.add_parser('compact');compact_parser.add_argument('--card',required=True) sub.add_parser('status');args=parser.parse_args() try: if args.command=='status': for card in update_index():print(f'{card["cardId"]} · {card["title"]}: {card["stage"]} · {card["selectedRevision"]} · {card["path"]}') elif args.command=='new':print(scaffold(args.card,args.title,args.rarity,args.revision)) elif args.command=='approve':print(approve(args.card,args.revision,args.approval_note)) elif args.command=='compact':print(json.dumps(compact_card_history(args.card),indent=2)) elif args.command=='refresh':print(refresh(resolve_revision(args.card,args.revision))['stage']);update_index() else:print(validate(resolve_revision(args.card,args.revision))['stage']) except (ValueError,FileNotFoundError,KeyError) as error:parser.exit(1,f'{error}\n')