@@ -195,6 +195,10 @@ export class ActorSync {
195195 }
196196 // Don't auto-create actors during initial sync — only update existing links.
197197 }
198+
199+ // Surface unresolved character links (broken / missing) so the GM can
200+ // fix them in the dashboard's Issues tab rather than silently desyncing.
201+ await this . _notifySyncIssues ( ) ;
198202 } catch ( err ) {
199203 console . warn ( 'Chronicle: Actor initial sync failed' , err ) ;
200204 }
@@ -754,6 +758,182 @@ export class ActorSync {
754758 return a . name . localeCompare ( b . name ) ;
755759 } ) ;
756760 }
761+
762+ // ---------------------------------------------------------------------------
763+ // Sync issue resolver — recover broken / missing character links and re-push
764+ // stranded data (Foundry → Chronicle). See sync-dashboard "Issues" tab.
765+ // ---------------------------------------------------------------------------
766+
767+ /**
768+ * The candidate pool for matching: every Chronicle character entity (the
769+ * system character type plus the PC sub-type, the same set onInitialSync walks).
770+ * @returns {Promise<Array<{id:string,name:string,entity_type_id?:number}>> }
771+ * @private
772+ */
773+ async _listCharacterEntities ( ) {
774+ if ( ! this . _characterTypeId ) return [ ] ;
775+ const typeIds = [ this . _characterTypeId ] ;
776+ if ( this . _pcSubtypeId ) typeIds . push ( this . _pcSubtypeId ) ;
777+ const out = [ ] ;
778+ for ( const typeId of typeIds ) {
779+ try {
780+ const result = await this . _api . get ( `/entities?type_id=${ typeId } &per_page=100` ) ;
781+ out . push ( ...( result ?. data || [ ] ) ) ;
782+ } catch ( err ) {
783+ console . warn ( 'Chronicle: failed to list character entities' , err ) ;
784+ }
785+ }
786+ return out ;
787+ }
788+
789+ /**
790+ * Whether a Chronicle entity still exists. Only a definite 404 counts as
791+ * "gone"; ambiguous/transient errors return true so a flaky network never
792+ * mislabels a healthy link as broken.
793+ * @param {string } entityId
794+ * @returns {Promise<boolean> }
795+ * @private
796+ */
797+ async _entityExists ( entityId ) {
798+ try {
799+ await this . _api . get ( `/entities/${ entityId } ` ) ;
800+ return true ;
801+ } catch ( err ) {
802+ const status = err ?. status ?? err ?. statusCode ?? err ?. response ?. status ;
803+ if ( status === 404 || / \b 4 0 4 \b | n o t f o u n d / i. test ( err ?. message || '' ) ) return false ;
804+ return true ;
805+ }
806+ }
807+
808+ /**
809+ * Best name match for an actor among candidate entities: exact
810+ * (case-insensitive), then prefix, then substring. Null when nothing is a
811+ * confident match (the resolver then defaults to "Create new").
812+ * @param {string } actorName
813+ * @param {Array<{id:string,name:string}> } entities
814+ * @returns {{id:string,name:string}|null }
815+ * @private
816+ */
817+ _suggestMatch ( actorName , entities ) {
818+ const n = ( actorName || '' ) . trim ( ) . toLowerCase ( ) ;
819+ if ( ! n ) return null ;
820+ const norm = ( e ) => ( e . name || '' ) . trim ( ) . toLowerCase ( ) ;
821+ let m = entities . find ( ( e ) => norm ( e ) === n ) ;
822+ if ( m ) return m ;
823+ m = entities . find ( ( e ) => { const en = norm ( e ) ; return en && ( en . startsWith ( n ) || n . startsWith ( en ) ) ; } ) ;
824+ if ( m ) return m ;
825+ m = entities . find ( ( e ) => { const en = norm ( e ) ; return en && ( en . includes ( n ) || n . includes ( en ) ) ; } ) ;
826+ return m || null ;
827+ }
828+
829+ /**
830+ * Scan character actors for issues the resolver can fix:
831+ * - 'unlinked': no entityId flag (never linked)
832+ * - 'broken': entityId flag points at an entity that 404s — "failed to
833+ * find existing character" (linked once, now gone)
834+ * Linked + valid actors aren't issues (re-pushing their data is the explicit
835+ * repushActor action). Each issue carries a name-matched suggestion, and the
836+ * result includes the full candidate list so the UI can prefill + offer a pick.
837+ * @returns {Promise<{issues:Array, candidates:Array<{id:string,name:string}>}> }
838+ */
839+ async getSyncIssues ( ) {
840+ if ( ! this . _adapter || ! this . _characterTypeId ) return { issues : [ ] , candidates : [ ] } ;
841+ const entities = await this . _listCharacterEntities ( ) ;
842+ const ids = new Set ( entities . map ( ( e ) => e . id ) ) ;
843+ const issues = [ ] ;
844+ for ( const actor of game . actors . contents ) {
845+ if ( actor . type !== this . _actorType ) continue ;
846+ const entityId = actor . getFlag ( FLAG_SCOPE , 'entityId' ) || null ;
847+ let kind = null ;
848+ if ( ! entityId ) {
849+ kind = 'unlinked' ;
850+ } else if ( ! ids . has ( entityId ) && ! ( await this . _entityExists ( entityId ) ) ) {
851+ kind = 'broken' ;
852+ }
853+ if ( ! kind ) continue ;
854+ const suggestion = this . _suggestMatch ( actor . name , entities ) ;
855+ issues . push ( { actorId : actor . id , name : actor . name , img : actor . img , kind, suggestionId : suggestion ?. id || null } ) ;
856+ }
857+ return {
858+ issues,
859+ candidates : entities . map ( ( e ) => ( { id : e . id , name : e . name } ) )
860+ . sort ( ( a , b ) => ( a . name || '' ) . localeCompare ( b . name || '' ) ) ,
861+ } ;
862+ }
863+
864+ /**
865+ * If any character actors can't be matched to Chronicle (broken / missing
866+ * links), nudge the GM toward the dashboard's Issues tab. Quiet when all clear.
867+ * @private
868+ */
869+ async _notifySyncIssues ( ) {
870+ try {
871+ const { issues } = await this . getSyncIssues ( ) ;
872+ if ( ! issues . length ) return ;
873+ const n = issues . length ;
874+ ui . notifications ?. warn (
875+ `Chronicle Sync: ${ n } character${ n === 1 ? '' : 's' } need attention — open the Chronicle dashboard (Issues) to match or create.`
876+ ) ;
877+ console . warn ( `Chronicle: ${ n } character sync issue(s) detected` , issues ) ;
878+ } catch ( err ) {
879+ console . warn ( 'Chronicle: issue check failed' , err ) ;
880+ }
881+ }
882+
883+ /**
884+ * Push a LINKED actor's current fields to its Chronicle entity. Fixes the
885+ * "stranded data on a valid link" case (actor edited while the module was
886+ * offline, so updateActor never fired).
887+ * @param {string } actorId
888+ * @returns {Promise<boolean> }
889+ */
890+ async repushActor ( actorId ) {
891+ const actor = game . actors . get ( actorId ) ;
892+ const entityId = actor ?. getFlag ( FLAG_SCOPE , 'entityId' ) ;
893+ if ( ! actor || ! entityId || ! this . _adapter ) return false ;
894+ await this . _api . put ( `/entities/${ entityId } /fields` , { fields_data : this . _adapter . toChronicleFields ( actor ) } ) ;
895+ this . _syncing = true ;
896+ try { await actor . setFlag ( FLAG_SCOPE , 'lastSync' , new Date ( ) . toISOString ( ) ) ; }
897+ finally { this . _syncing = false ; }
898+ console . debug ( `Chronicle: Re-pushed actor "${ actor . name } " → entity ${ entityId } ` ) ;
899+ return true ;
900+ }
901+
902+ /**
903+ * Link an orphaned actor to an EXISTING Chronicle entity, then push the
904+ * actor's current data up (Foundry is source of truth for characters).
905+ * @param {string } actorId
906+ * @param {string } entityId
907+ * @returns {Promise<boolean> }
908+ */
909+ async matchActorToEntity ( actorId , entityId ) {
910+ const actor = game . actors . get ( actorId ) ;
911+ if ( ! actor || ! entityId || ! this . _adapter ) return false ;
912+ this . _syncing = true ;
913+ try { await actor . setFlag ( FLAG_SCOPE , 'entityId' , entityId ) ; }
914+ finally { this . _syncing = false ; }
915+ return this . repushActor ( actorId ) ;
916+ }
917+
918+ /**
919+ * Create a NEW Chronicle entity of the SYSTEM'S character type for an orphaned
920+ * actor and link it. Clears any stale/broken link first so _handleCreateActor
921+ * creates fresh instead of skipping an "already linked" actor.
922+ * @param {string } actorId
923+ * @returns {Promise<boolean> }
924+ */
925+ async createEntityForActor ( actorId ) {
926+ const actor = game . actors . get ( actorId ) ;
927+ if ( ! actor ) return false ;
928+ if ( actor . getFlag ( FLAG_SCOPE , 'entityId' ) ) {
929+ this . _syncing = true ;
930+ try { await actor . unsetFlag ( FLAG_SCOPE , 'entityId' ) ; }
931+ finally { this . _syncing = false ; }
932+ }
933+ await this . _handleCreateActor ( actor , { } , game . user . id ) ;
934+ return ! ! actor . getFlag ( FLAG_SCOPE , 'entityId' ) ;
935+ }
936+
757937 /**
758938 * Return true when any actor of the character type has a non-GM Foundry
759939 * owner. Used by the dashboard to surface the PC-claiming hint when the
0 commit comments