Skip to content

Commit 5a70167

Browse files
committed
CYOA: Allow user to control tone/pacing
1 parent 560dd23 commit 5a70167

6 files changed

Lines changed: 481 additions & 98 deletions

File tree

src/calibre/ai/cyoa.py

Lines changed: 246 additions & 41 deletions
Large diffs are not rendered by default.

src/calibre/gui2/cyoa/data.py

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from typing import TYPE_CHECKING, Any, Literal, NamedTuple
2828

2929
from 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
3131
from calibre.ai.prefs import override_prefs_for_providers, plugins_for_purpose, update_prefs_for_provider
3232
from calibre.ai.structured import instantiate, spec_for_class
3333
from 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

546553
def 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:

src/calibre/gui2/cyoa/game.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,10 @@ def edit_world(self) -> None:
13201320
upcoming_events=memory.upcoming_events,
13211321
)
13221322
)
1323+
# Takes effect from the next turn: the instructions sent to the AI are
1324+
# built from the game state every turn, so the prose already written
1325+
# keeps the style it was written in.
1326+
state.style = d.updated_style
13231327
# The saved world the game started from is only its template, so it is
13241328
# deliberately left alone: the edited characters and their portraits
13251329
# belong to this game and are stored with it.

src/calibre/gui2/cyoa/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from qt.core import QFont, QIcon, QSize, QStackedWidget
1111

12-
from calibre.ai.cyoa import PROTAGONIST_ID, GeneratedWorld, start_game
12+
from calibre.ai.cyoa import PROTAGONIST_ID, GeneratedWorld, StoryStyle, start_game
1313
from calibre.constants import CYOA_APP_UID, islinux
1414
from calibre.gui2 import Application, error_dialog, gprefs
1515
from calibre.gui2.cyoa import data
@@ -60,10 +60,10 @@ def show_appropriate_page(self) -> None:
6060
self.world.reset()
6161
self.stack.setCurrentWidget(self.world)
6262

63-
def start_new_game(self, world: GeneratedWorld, character_index: int, brief: str, art_style: str, portrait: dict[str, str] | None) -> None:
63+
def start_new_game(self, world: GeneratedWorld, character_index: int, brief: str, style: StoryStyle, portrait: dict[str, str] | None) -> None:
6464
# The portrait of the chosen character comes from the world it was
6565
# generated in, but from now on the game owns its own copy of it.
66-
state = start_game(brief, world, character_index, art_style)
66+
state = start_game(brief, world, character_index, style)
6767
portraits = {PROTAGONIST_ID: portrait} if portrait else {}
6868
game_id = data.new_game_id()
6969
data.save_game(game_id, state, portraits=portraits)

0 commit comments

Comments
 (0)