276 lines
18 KiB
Python
276 lines
18 KiB
Python
#!/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,
|
||
REPO/'in-progress/cards'/name/'history'/f'accepted-{revision}']
|
||
return [path for path in candidates if (path/'manifest.json').is_file() and read(path/'manifest.json')['revision']==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 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):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)
|
||
profile_path=REPO/'in-progress/cards'/name/'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')
|
||
target=REPO/'artifacts/cards'/name
|
||
if target.exists():
|
||
previous=validate(target)
|
||
if previous['stage']!='approved':raise ValueError('Existing artifact is not an approved card')
|
||
archive=REPO/'in-progress/cards'/name/'history'/f"accepted-{previous['revision']}"
|
||
if archive.exists():raise ValueError('Previous acceptance archive already exists')
|
||
# Pin incoming references to the old accepted artwork before replacing its location.
|
||
rebase_linked_inputs(source,target,archive)
|
||
relocate_revision(target,archive,previous.get('compatibilityAliases',[]))
|
||
refresh(archive,allow_approved=True)
|
||
target=relocate_revision(source,target,aliases)
|
||
manifest['stage']='approved';manifest['approval']={'by':'user','note':note};write(target/'manifest.json',manifest)
|
||
base=REPO/'in-progress/cards'/name;profile=read(base/'card.json');profile.update({'selectedRevision':revision,'stage':'approved','approval':manifest['approval']});write(base/'card.json',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 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)
|
||
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=='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')
|