2727from typing import TYPE_CHECKING , Any , Literal , NamedTuple
2828
2929from calibre .ai import AICapabilities
30- from calibre .ai .cyoa import PROTAGONIST_ID , GameState , GeneratedWorld , as_jsonable , character_id_for_name , deserialize_game , serialize_game
30+ from calibre .ai .cyoa import PROTAGONIST_ID , GameState , GeneratedWorld , StoryStyle , as_jsonable , character_id_for_name , deserialize_game , serialize_game
3131from calibre .ai .prefs import override_prefs_for_providers , plugins_for_purpose , update_prefs_for_provider
3232from calibre .ai .structured import instantiate , spec_for_class
3333from calibre .constants import config_dir
@@ -495,7 +495,9 @@ def saved_world_index_with_title(title: str) -> int:
495495 return - 1
496496
497497
498- def add_saved_world (brief : str , world : GeneratedWorld , art_style : str = '' , portraits : Sequence [dict [str , str ] | None ] = (), world_id : str = '' ) -> str :
498+ def add_saved_world (
499+ brief : str , world : GeneratedWorld , style : StoryStyle = StoryStyle (), portraits : Sequence [dict [str , str ] | None ] = (), world_id : str = ''
500+ ) -> str :
499501 # Save the world, updating the entry with the specified id, or the first
500502 # entry with the same title when no id is given. Returns the id of the
501503 # saved entry, which keeps identifying it however the world is renamed.
@@ -510,20 +512,18 @@ def add_saved_world(brief: str, world: GeneratedWorld, art_style: str = '', port
510512 worlds = p ['worlds' ]
511513 idx = saved_world_index_with_id (world_id ) if world_id else saved_world_index_with_title (world .title )
512514 existing = worlds [idx ] if idx > - 1 else {}
513- if (
514- world_id_from_saved (existing )
515- and existing .get ('world' ) == jw
516- and (existing .get ('art_style' ) or '' ) == art_style
517- and (existing .get ('portraits' ) or []) == pl
518- ):
515+ if world_id_from_saved (existing ) and existing .get ('world' ) == jw and style_from_saved (existing ) == style and (existing .get ('portraits' ) or []) == pl :
519516 return world_id_from_saved (existing ) # nothing has changed
517+ # The fields of the style are stored individually, at the top level, so
518+ # that a world saved before one of them existed still loads, with that
519+ # field unset, see style_from_saved().
520520 entry : dict [str , Any ] = {
521521 'id' : world_id_from_saved (existing ) or uuid4 (),
522522 'brief' : brief ,
523523 'created' : existing .get ('created' ) or time (),
524524 'world' : jw ,
525- 'art_style' : art_style ,
526525 'portraits' : pl ,
526+ ** style ._asdict (),
527527 }
528528 if idx > - 1 :
529529 worlds [idx ] = entry
@@ -539,8 +539,15 @@ def world_from_saved(entry: dict[str, Any]) -> GeneratedWorld:
539539 return ans
540540
541541
542- def art_style_from_saved (entry : dict [str , Any ]) -> str :
543- return str (entry .get ('art_style' ) or '' )
542+ def style_from_saved (entry : dict [str , Any ]) -> StoryStyle :
543+ # The styles the world was saved with, with any that the version of
544+ # calibre that saved it did not have left unset, which means the default.
545+ return StoryStyle (
546+ art_style = str (entry .get ('art_style' ) or '' ),
547+ pace = str (entry .get ('pace' ) or '' ),
548+ tone = str (entry .get ('tone' ) or '' ),
549+ narration = str (entry .get ('narration' ) or '' ),
550+ )
544551
545552
546553def portraits_from_saved (entry : dict [str , Any ], num_characters : int ) -> list [dict [str , str ] | None ]:
@@ -684,18 +691,21 @@ def test_cyoa_saved_worlds(self) -> None:
684691 add_saved_world ('sunny brief' , other )
685692 self .ae (len (saved_worlds ()), 2 , 'a world with a different title must not replace existing worlds' )
686693 entry = saved_worlds ()[saved_world_index_with_title ('Sun City' )]
687- self .ae (art_style_from_saved (entry ), ' ' )
694+ self .ae (style_from_saved (entry ), StoryStyle (), 'a world saved without styles must load with the defaults ' )
688695 self .ae (portraits_from_saved (entry , 1 ), [None ])
689696 portrait = {'mime' : 'image/webp' , 'data' : 'abcd' }
690- add_saved_world ('sunny brief' , other , 'anime' , [portrait ])
697+ style = StoryStyle (art_style = 'anime' , pace = 'short' , tone = 'comedic' , narration = 'third-past' )
698+ add_saved_world ('sunny brief' , other , style , [portrait ])
691699 self .ae (len (saved_worlds ()), 2 , 'adding portraits must update the existing saved world, not create a new one' )
692700 entry = saved_worlds ()[saved_world_index_with_title ('Sun City' )]
693- self .ae (art_style_from_saved (entry ), 'anime' )
701+ self .ae (style_from_saved (entry ), style )
702+ del entry ['pace' ]
703+ self .ae (style_from_saved (entry ).pace , '' , 'a world saved before a style existed must load with that style unset' )
694704 self .ae (portraits_from_saved (entry , 1 ), [portrait ])
695705 self .ae (portraits_from_saved (entry , 2 ), [portrait , None ], 'missing portraits must be padded with None' )
696706 self .ae (portraits_from_saved (entry , 0 ), [], 'extra portraits must be discarded' )
697707 created = entry ['created' ]
698- wid = add_saved_world ('sunny brief' , other , 'anime' , [portrait ])
708+ wid = add_saved_world ('sunny brief' , other , style , [portrait ])
699709 entry = saved_worlds ()[saved_world_index_with_title ('Sun City' )]
700710 self .ae (entry ['created' ], created , 'saving an identical world must not change it' )
701711
@@ -706,7 +716,7 @@ def test_cyoa_saved_worlds(self) -> None:
706716 self .ae (saved_world_index_with_id ('no-such-id' ), - 1 )
707717 self .ae (saved_world_index_with_id ('' ), - 1 )
708718 renamed = other ._replace (title = 'Storm City' )
709- self .ae (add_saved_world ('sunny brief' , renamed , 'anime' , [portrait ], world_id = wid ), wid )
719+ self .ae (add_saved_world ('sunny brief' , renamed , style , [portrait ], world_id = wid ), wid )
710720 self .ae (len (saved_worlds ()), 2 , 'renaming a saved world must not create a second entry' )
711721 entry = saved_worlds ()[saved_world_index_with_id (wid )]
712722 self .ae (world_from_saved (entry ).title , 'Storm City' )
@@ -749,7 +759,7 @@ def test_cyoa_game_file_migration(self) -> None:
749759 player_portrait = {'mime' : 'image/webp' , 'data' : 'player' }
750760 npc_portrait = {'mime' : 'image/webp' , 'data' : 'npc' }
751761 # Version 1 kept the portraits of the playable characters in the saved world
752- add_saved_world ('brief' , world , 'anime' , [None , player_portrait ])
762+ add_saved_world ('brief' , world , StoryStyle ( art_style = 'anime' ) , [None , player_portrait ])
753763 gid = new_game_id (tdir )
754764 save_game (gid , state , base = tdir )
755765 with open (game_file (gid , tdir ), 'rb' ) as f :
@@ -782,13 +792,15 @@ def test_cyoa_game_file_migration(self) -> None:
782792 self .ae (load_game (gid , base = tdir )[2 ], {'marlo' : npc_portrait })
783793
784794 def test_cyoa_premade_world_art_styles (self ) -> None :
785- from calibre .ai .cyoa import art_style_for_key
786- from calibre .gui2 .cyoa .world import PREMADE_WORLDS , recommended_art_style
795+ from calibre .ai .cyoa import ART_STYLES , TONES , style_for_key
796+ from calibre .gui2 .cyoa .world import PREMADE_WORLDS , recommended_style
787797
788798 for pw in PREMADE_WORLDS :
789- self .ae (art_style_for_key (pw .art_style ).key , pw .art_style , f'the recommended art style for { pw .title !r} must be a valid art style key' )
790- self .ae (recommended_art_style (pw .brief ), pw .art_style )
791- self .ae (recommended_art_style ('not a pre-made brief' ), '' , 'a custom brief must not have a recommended art style' )
799+ self .ae (style_for_key (ART_STYLES , pw .art_style ).key , pw .art_style , f'the art style recommended for { pw .title !r} must be a valid key' )
800+ if pw .tone :
801+ self .ae (style_for_key (TONES , pw .tone ).key , pw .tone , f'the tone recommended for { pw .title !r} must be a valid tone key' )
802+ self .ae (recommended_style (pw .brief ), StoryStyle (art_style = pw .art_style , tone = pw .tone ))
803+ self .ae (recommended_style ('not a pre-made brief' ), StoryStyle (), 'a custom brief must not have recommended styles' )
792804
793805 def test_cyoa_text_display_settings (self ) -> None :
794806 with tempfile .TemporaryDirectory () as tdir :
0 commit comments