tfrere commited on
Commit
78b81a5
·
1 Parent(s): b239fb1

update story

Browse files
client/src/components/GameNavigation.jsx CHANGED
@@ -2,8 +2,11 @@ import { IconButton, Tooltip } from "@mui/material";
2
  import ArrowBackIcon from "@mui/icons-material/ArrowBack";
3
  import VolumeOffIcon from "@mui/icons-material/VolumeOff";
4
  import VolumeUpIcon from "@mui/icons-material/VolumeUp";
 
5
  import { useNavigate } from "react-router-dom";
6
  import { useSoundSystem } from "../contexts/SoundContext";
 
 
7
  import { storyApi } from "../utils/api";
8
 
9
  const SOUND_ENABLED_KEY = "sound_enabled";
@@ -11,6 +14,8 @@ const SOUND_ENABLED_KEY = "sound_enabled";
11
  export function GameNavigation() {
12
  const navigate = useNavigate();
13
  const { isSoundEnabled, setIsSoundEnabled, playSound } = useSoundSystem();
 
 
14
 
15
  const handleBack = () => {
16
  playSound("page");
@@ -24,8 +29,21 @@ export function GameNavigation() {
24
  storyApi.setSoundEnabled(newSoundState);
25
  };
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  return (
28
- <div style={{ position: "relative", zIndex: 1000 }}>
29
  {window.location.pathname !== "/" && (
30
  <Tooltip title="Back to home">
31
  <IconButton
@@ -48,6 +66,25 @@ export function GameNavigation() {
48
  </Tooltip>
49
  )}
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  <Tooltip title={isSoundEnabled ? "Mute sound" : "Unmute sound"}>
52
  <IconButton
53
  onClick={handleToggleSound}
 
2
  import ArrowBackIcon from "@mui/icons-material/ArrowBack";
3
  import VolumeOffIcon from "@mui/icons-material/VolumeOff";
4
  import VolumeUpIcon from "@mui/icons-material/VolumeUp";
5
+ import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
6
  import { useNavigate } from "react-router-dom";
7
  import { useSoundSystem } from "../contexts/SoundContext";
8
+ import { useStoryCapture } from "../hooks/useStoryCapture";
9
+ import { useRef } from "react";
10
  import { storyApi } from "../utils/api";
11
 
12
  const SOUND_ENABLED_KEY = "sound_enabled";
 
14
  export function GameNavigation() {
15
  const navigate = useNavigate();
16
  const { isSoundEnabled, setIsSoundEnabled, playSound } = useSoundSystem();
17
+ const { downloadStoryImage } = useStoryCapture();
18
+ const containerRef = useRef(null);
19
 
20
  const handleBack = () => {
21
  playSound("page");
 
29
  storyApi.setSoundEnabled(newSoundState);
30
  };
31
 
32
+ const handleCapture = async () => {
33
+ playSound("page");
34
+ const container = document.querySelector(
35
+ "[data-comic-layout]"
36
+ )?.parentElement;
37
+ if (container) {
38
+ await downloadStoryImage(
39
+ { current: container },
40
+ `your-story-${Date.now()}.png`
41
+ );
42
+ }
43
+ };
44
+
45
  return (
46
+ <div style={{ position: "relative", zIndex: 1000 }} ref={containerRef}>
47
  {window.location.pathname !== "/" && (
48
  <Tooltip title="Back to home">
49
  <IconButton
 
66
  </Tooltip>
67
  )}
68
 
69
+ <Tooltip title="Capture story">
70
+ <IconButton
71
+ onClick={handleCapture}
72
+ sx={{
73
+ position: "fixed",
74
+ top: 24,
75
+ right: 88,
76
+ color: "white",
77
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
78
+ "&:hover": {
79
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
80
+ },
81
+ pointerEvents: "auto",
82
+ }}
83
+ >
84
+ <PhotoCameraOutlinedIcon />
85
+ </IconButton>
86
+ </Tooltip>
87
+
88
  <Tooltip title={isSoundEnabled ? "Mute sound" : "Unmute sound"}>
89
  <IconButton
90
  onClick={handleToggleSound}
client/src/components/StoryChoices.jsx CHANGED
@@ -112,6 +112,7 @@ export function StoryChoices() {
112
 
113
  return (
114
  <Box
 
115
  sx={{
116
  display: "flex",
117
  flexDirection: isMobile ? "column" : "row",
 
112
 
113
  return (
114
  <Box
115
+ data-story-choices
116
  sx={{
117
  display: "flex",
118
  flexDirection: isMobile ? "column" : "row",
client/src/components/UniverseSlotMachine.jsx CHANGED
@@ -209,7 +209,7 @@ export const UniverseSlotMachine = ({
209
  }}
210
  >
211
  <SlotSection
212
- label="Told in"
213
  value={style}
214
  words={RANDOM_STYLES}
215
  delay={0}
 
209
  }}
210
  >
211
  <SlotSection
212
+ label=""
213
  value={style}
214
  words={RANDOM_STYLES}
215
  delay={0}
client/src/hooks/useStoryCapture.js CHANGED
@@ -7,6 +7,38 @@ export function useStoryCapture() {
7
  quality: 1.0,
8
  });
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  const captureStory = useCallback(
11
  async (containerRef) => {
12
  if (!containerRef.current) return null;
@@ -21,17 +53,24 @@ export function useStoryCapture() {
21
  // Save original styles
22
  const originalStyle = element.style.cssText;
23
  const originalScroll = element.scrollLeft;
 
 
 
 
 
 
24
 
25
- // Reset scroll and padding temporarily for the screenshot
26
  Object.assign(element.style, {
27
- paddingLeft: "0",
28
- paddingRight: "0",
29
- width: `${element.scrollWidth - 975}px`, // Reduce width by choices panel
30
  display: "flex",
31
  flexDirection: "row",
32
  gap: "32px",
33
  padding: "32px",
 
 
34
  overflow: "hidden",
 
35
  });
36
  element.scrollLeft = 0;
37
 
@@ -41,7 +80,7 @@ export function useStoryCapture() {
41
  // Take screenshot
42
  const result = await takeScreenshot(element, {
43
  backgroundColor: "#242424",
44
- width: element.offsetWidth,
45
  height: element.scrollHeight,
46
  style: {
47
  transform: "none",
@@ -51,6 +90,9 @@ export function useStoryCapture() {
51
 
52
  // Restore original styles
53
  element.style.cssText = originalStyle;
 
 
 
54
  element.scrollLeft = originalScroll;
55
 
56
  return result;
 
7
  quality: 1.0,
8
  });
9
 
10
+ const calculateOptimalWidth = (element) => {
11
+ // Get all comic pages
12
+ const comicPages = element.querySelectorAll("[data-comic-page]");
13
+ if (!comicPages.length) return element.scrollWidth;
14
+
15
+ // Get width of a single page (they all have the same width)
16
+ const firstPage = comicPages[0];
17
+ const pageWidth = firstPage.offsetWidth;
18
+ const gap = 32; // Fixed gap between pages
19
+ const padding = 32; // Fixed padding on both sides
20
+
21
+ // Calculate total width:
22
+ // - All pages width (pageWidth * nbPages)
23
+ // - Gaps only between pages, so (nbPages - 1) gaps
24
+ // - Padding on both sides
25
+ const totalWidth =
26
+ pageWidth * comicPages.length +
27
+ (comicPages.length > 1 ? gap * (comicPages.length - 1) : 0) +
28
+ padding * 2;
29
+
30
+ console.log("Width calculation:", {
31
+ numberOfPages: comicPages.length,
32
+ pageWidth,
33
+ gapBetweenPages: gap,
34
+ totalGaps: comicPages.length > 1 ? gap * (comicPages.length - 1) : 0,
35
+ padding,
36
+ totalWidth,
37
+ });
38
+
39
+ return totalWidth;
40
+ };
41
+
42
  const captureStory = useCallback(
43
  async (containerRef) => {
44
  if (!containerRef.current) return null;
 
53
  // Save original styles
54
  const originalStyle = element.style.cssText;
55
  const originalScroll = element.scrollLeft;
56
+ const originalWidth = element.style.width;
57
+ const originalPadding = element.style.padding;
58
+ const originalGap = element.style.gap;
59
+
60
+ // Calculate optimal width
61
+ const optimalWidth = calculateOptimalWidth(element);
62
 
63
+ // Reset scroll and styles temporarily for the screenshot
64
  Object.assign(element.style, {
65
+ width: `${optimalWidth}px`,
 
 
66
  display: "flex",
67
  flexDirection: "row",
68
  gap: "32px",
69
  padding: "32px",
70
+ paddingLeft: "32px !important", // Force override the dynamic padding
71
+ paddingRight: "32px !important", // Force override the dynamic padding
72
  overflow: "hidden",
73
+ transition: "none", // Disable transitions during capture
74
  });
75
  element.scrollLeft = 0;
76
 
 
80
  // Take screenshot
81
  const result = await takeScreenshot(element, {
82
  backgroundColor: "#242424",
83
+ width: optimalWidth,
84
  height: element.scrollHeight,
85
  style: {
86
  transform: "none",
 
90
 
91
  // Restore original styles
92
  element.style.cssText = originalStyle;
93
+ element.style.width = originalWidth;
94
+ element.style.padding = originalPadding;
95
+ element.style.gap = originalGap;
96
  element.scrollLeft = originalScroll;
97
 
98
  return result;
client/src/layouts/ComicLayout.jsx CHANGED
@@ -50,6 +50,22 @@ function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
50
  return total + (segment.images?.length || 0);
51
  }, 0);
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  // Son d'écriture
54
  const playWritingSound = useSoundEffect({
55
  basePath: "/sounds/drawing-",
@@ -136,6 +152,7 @@ function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
136
  }}
137
  >
138
  <Box
 
139
  sx={{
140
  display: "grid",
141
  gridTemplateColumns: `repeat(${LAYOUTS[layout.type].gridCols}, 1fr)`,
@@ -189,6 +206,7 @@ function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
189
  handleImageLoad(`page-${layoutIndex}-image-${panelIndex}`)
190
  }
191
  imageId={`page-${layoutIndex}-image-${panelIndex}`}
 
192
  />
193
  );
194
  })}
 
50
  return total + (segment.images?.length || 0);
51
  }, 0);
52
 
53
+ // Sélectionner aléatoirement un panneau qui accepte le texte
54
+ const [selectedTextPanelIndex] = useState(() => {
55
+ const acceptingPanels = LAYOUTS[layout.type].panels
56
+ .slice(0, totalImages)
57
+ .map((panel, index) => ({ panel, index }))
58
+ .filter(({ panel }) => panel.acceptText);
59
+
60
+ if (acceptingPanels.length === 0) {
61
+ // Si aucun panneau n'accepte le texte, utiliser le premier panneau par défaut
62
+ return 0;
63
+ }
64
+ // Sélectionner un panneau aléatoire parmi ceux qui acceptent le texte
65
+ const randomIndex = Math.floor(Math.random() * acceptingPanels.length);
66
+ return acceptingPanels[randomIndex].index;
67
+ });
68
+
69
  // Son d'écriture
70
  const playWritingSound = useSoundEffect({
71
  basePath: "/sounds/drawing-",
 
152
  }}
153
  >
154
  <Box
155
+ data-comic-page
156
  sx={{
157
  display: "grid",
158
  gridTemplateColumns: `repeat(${LAYOUTS[layout.type].gridCols}, 1fr)`,
 
206
  handleImageLoad(`page-${layoutIndex}-image-${panelIndex}`)
207
  }
208
  imageId={`page-${layoutIndex}-image-${panelIndex}`}
209
+ showText={panelIndex === selectedTextPanelIndex}
210
  />
211
  );
212
  })}
client/src/layouts/Panel.jsx CHANGED
@@ -43,6 +43,7 @@ export function Panel({
43
  totalImagesInPage,
44
  onImageLoad,
45
  imageId,
 
46
  }) {
47
  const { regenerateImage } = useGame();
48
  const [imageLoaded, setImageLoaded] = useState(
@@ -54,7 +55,6 @@ export function Panel({
54
  const [isRegenerating, setIsRegenerating] = useState(false);
55
  const [isSpinning, setIsSpinning] = useState(false);
56
  const hasImage = segment?.images?.[panelIndex];
57
- const isFirstPanel = panelIndex === 0;
58
  const imgRef = useRef(null);
59
  const imageDataRef = useRef(null);
60
  const mountedRef = useRef(true);
@@ -234,7 +234,7 @@ export function Panel({
234
  <RefreshIcon />
235
  </IconButton>
236
  </Tooltip>
237
- {isFirstPanel && segment?.text && (
238
  <Box
239
  sx={{
240
  position: "absolute",
@@ -248,7 +248,10 @@ export function Panel({
248
  fontWeight: 500,
249
  borderRadius: "8px",
250
  display: "flex",
 
251
  alignItems: "center",
 
 
252
  fontSize: { xs: "0.775rem", sm: "1rem" }, // Responsive font size
253
  color: "black",
254
  lineHeight: 1.1,
 
43
  totalImagesInPage,
44
  onImageLoad,
45
  imageId,
46
+ showText,
47
  }) {
48
  const { regenerateImage } = useGame();
49
  const [imageLoaded, setImageLoaded] = useState(
 
55
  const [isRegenerating, setIsRegenerating] = useState(false);
56
  const [isSpinning, setIsSpinning] = useState(false);
57
  const hasImage = segment?.images?.[panelIndex];
 
58
  const imgRef = useRef(null);
59
  const imageDataRef = useRef(null);
60
  const mountedRef = useRef(true);
 
234
  <RefreshIcon />
235
  </IconButton>
236
  </Tooltip>
237
+ {showText && segment?.text && (
238
  <Box
239
  sx={{
240
  position: "absolute",
 
248
  fontWeight: 500,
249
  borderRadius: "8px",
250
  display: "flex",
251
+ flexDirection: "column",
252
  alignItems: "center",
253
+ gap: 1,
254
+ boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
255
  fontSize: { xs: "0.775rem", sm: "1rem" }, // Responsive font size
256
  color: "black",
257
  lineHeight: 1.1,
client/src/layouts/config.js CHANGED
@@ -21,17 +21,42 @@ export const LAYOUTS = {
21
  gridCols: 1,
22
  gridRows: 1,
23
  panels: [
24
- { ...PANEL_SIZES.COVER_SIZE, gridColumn: "1", gridRow: "1" }, // Format portrait
 
 
 
 
 
25
  ],
26
  },
27
  LAYOUT_1: {
28
  gridCols: 2,
29
  gridRows: 2,
30
  panels: [
31
- { ...PANEL_SIZES.LANDSCAPE, gridColumn: "1", gridRow: "1" }, // Landscape top left
32
- { ...PANEL_SIZES.PORTRAIT, gridColumn: "2", gridRow: "1" }, // Portrait top right
33
- { ...PANEL_SIZES.LANDSCAPE, gridColumn: "1", gridRow: "2" }, // Landscape middle left
34
- { ...PANEL_SIZES.PORTRAIT, gridColumn: "2", gridRow: "2" }, // Portrait right
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  ],
36
  },
37
  LAYOUT_2: {
@@ -42,45 +67,103 @@ export const LAYOUTS = {
42
  ...PANEL_SIZES.LANDSCAPE,
43
  gridColumn: GRID.TWO_THIRDS,
44
  gridRow: "1",
 
45
  }, // Large square top left
46
- { ...PANEL_SIZES.PORTRAIT, gridColumn: "3", gridRow: "1" }, // Portrait top right
47
- { ...PANEL_SIZES.LANDSCAPE, gridColumn: GRID.FULL_WIDTH, gridRow: "2" }, // Full width landscape bottom
 
 
 
 
 
 
 
 
 
 
48
  ],
49
  },
50
  LAYOUT_3: {
51
  gridCols: 3,
52
  gridRows: 2,
53
  panels: [
54
- { ...PANEL_SIZES.SQUARE, gridColumn: GRID.TWO_THIRDS, gridRow: "1" }, // Wide landscape top left
55
- { ...PANEL_SIZES.COLUMN, gridColumn: "3", gridRow: "1" }, // COLUMN top right
56
- { ...PANEL_SIZES.COLUMN, gridColumn: "1", gridRow: "2" }, // COLUMN bottom left
57
- { ...PANEL_SIZES.SQUARE, gridColumn: "2 / span 2", gridRow: "2" }, // Wide landscape bottom right
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  ],
59
  },
60
  LAYOUT_4: {
61
  gridCols: 2,
62
  gridRows: 3,
63
  panels: [
64
- { ...PANEL_SIZES.PANORAMIC, gridColumn: "1 / span 2", gridRow: "1" }, // Wide panoramic top
 
 
 
 
 
65
  {
66
  ...PANEL_SIZES.COLUMN,
67
  gridColumn: "1",
68
  gridRow: GRID.FULL_HEIGHT_FROM_2,
 
69
  }, // Tall portrait left
70
- { ...PANEL_SIZES.SQUARE, gridColumn: "2", gridRow: "2" }, // Square middle right
71
- { ...PANEL_SIZES.SQUARE, gridColumn: "2", gridRow: "3" }, // Square bottom right
 
 
 
 
 
 
 
 
 
 
72
  ],
73
  },
74
  LAYOUT_5: {
75
  gridCols: 3,
76
  gridRows: 3,
77
  panels: [
78
- { ...PANEL_SIZES.PANORAMIC, gridColumn: GRID.FULL_WIDTH, gridRow: "1" }, // Wide panoramic top
79
- { ...PANEL_SIZES.COLUMN, gridColumn: "1", gridRow: "2 / span 2" }, // Tall portrait left
 
 
 
 
 
 
 
 
 
 
80
  {
81
  ...PANEL_SIZES.POTRAIT,
82
  gridColumn: "2 / span 2",
83
  gridRow: "2 / span 2",
 
84
  }, // Large square right
85
  ],
86
  },
@@ -88,8 +171,18 @@ export const LAYOUTS = {
88
  gridCols: 1,
89
  gridRows: 2,
90
  panels: [
91
- { ...PANEL_SIZES.LANDSCAPE, gridColumn: GRID.FULL_WIDTH, gridRow: "1" }, // Portrait top right
92
- { ...PANEL_SIZES.LANDSCAPE, gridColumn: GRID.FULL_WIDTH, gridRow: "2" }, // Full width landscape bottom
 
 
 
 
 
 
 
 
 
 
93
  ],
94
  },
95
  };
@@ -103,8 +196,8 @@ export const nonRandomLayouts = Object.keys(LAYOUTS).filter(
103
  export const LAYOUTS_BY_PANEL_COUNT = {
104
  1: ["COVER"],
105
  2: ["LAYOUT_7"],
106
- 3: ["LAYOUT_2"], //"LAYOUT_5"
107
- 4: ["LAYOUT_3"], //, "LAYOUT_4"
108
  };
109
 
110
  // Helper functions for layout configuration
 
21
  gridCols: 1,
22
  gridRows: 1,
23
  panels: [
24
+ {
25
+ ...PANEL_SIZES.COVER_SIZE,
26
+ gridColumn: "1",
27
+ gridRow: "1",
28
+ acceptText: true,
29
+ }, // Format portrait
30
  ],
31
  },
32
  LAYOUT_1: {
33
  gridCols: 2,
34
  gridRows: 2,
35
  panels: [
36
+ {
37
+ ...PANEL_SIZES.LANDSCAPE,
38
+ gridColumn: "1",
39
+ gridRow: "1",
40
+ acceptText: true,
41
+ }, // Landscape top left
42
+ {
43
+ ...PANEL_SIZES.PORTRAIT,
44
+ gridColumn: "2",
45
+ gridRow: "1",
46
+ acceptText: false,
47
+ }, // Portrait top right
48
+ {
49
+ ...PANEL_SIZES.LANDSCAPE,
50
+ gridColumn: "1",
51
+ gridRow: "2",
52
+ acceptText: true,
53
+ }, // Landscape middle left
54
+ {
55
+ ...PANEL_SIZES.PORTRAIT,
56
+ gridColumn: "2",
57
+ gridRow: "2",
58
+ acceptText: false,
59
+ }, // Portrait right
60
  ],
61
  },
62
  LAYOUT_2: {
 
67
  ...PANEL_SIZES.LANDSCAPE,
68
  gridColumn: GRID.TWO_THIRDS,
69
  gridRow: "1",
70
+ acceptText: true,
71
  }, // Large square top left
72
+ {
73
+ ...PANEL_SIZES.PORTRAIT,
74
+ gridColumn: "3",
75
+ gridRow: "1",
76
+ acceptText: false,
77
+ }, // Portrait top right
78
+ {
79
+ ...PANEL_SIZES.LANDSCAPE,
80
+ gridColumn: GRID.FULL_WIDTH,
81
+ gridRow: "2",
82
+ acceptText: false,
83
+ }, // Full width landscape bottom
84
  ],
85
  },
86
  LAYOUT_3: {
87
  gridCols: 3,
88
  gridRows: 2,
89
  panels: [
90
+ {
91
+ ...PANEL_SIZES.SQUARE,
92
+ gridColumn: GRID.TWO_THIRDS,
93
+ gridRow: "1",
94
+ acceptText: true,
95
+ }, // Wide landscape top left
96
+ {
97
+ ...PANEL_SIZES.COLUMN,
98
+ gridColumn: "3",
99
+ gridRow: "1",
100
+ acceptText: false,
101
+ }, // COLUMN top right
102
+ {
103
+ ...PANEL_SIZES.COLUMN,
104
+ gridColumn: "1",
105
+ gridRow: "2",
106
+ acceptText: false,
107
+ }, // COLUMN bottom left
108
+ {
109
+ ...PANEL_SIZES.SQUARE,
110
+ gridColumn: "2 / span 2",
111
+ gridRow: "2",
112
+ acceptText: true,
113
+ }, // Wide landscape bottom right
114
  ],
115
  },
116
  LAYOUT_4: {
117
  gridCols: 2,
118
  gridRows: 3,
119
  panels: [
120
+ {
121
+ ...PANEL_SIZES.PANORAMIC,
122
+ gridColumn: "1 / span 2",
123
+ gridRow: "1",
124
+ acceptText: true,
125
+ }, // Wide panoramic top
126
  {
127
  ...PANEL_SIZES.COLUMN,
128
  gridColumn: "1",
129
  gridRow: GRID.FULL_HEIGHT_FROM_2,
130
+ acceptText: false,
131
  }, // Tall portrait left
132
+ {
133
+ ...PANEL_SIZES.SQUARE,
134
+ gridColumn: "2",
135
+ gridRow: "2",
136
+ acceptText: false,
137
+ }, // Square middle right
138
+ {
139
+ ...PANEL_SIZES.SQUARE,
140
+ gridColumn: "2",
141
+ gridRow: "3",
142
+ acceptText: false,
143
+ }, // Square bottom right
144
  ],
145
  },
146
  LAYOUT_5: {
147
  gridCols: 3,
148
  gridRows: 3,
149
  panels: [
150
+ {
151
+ ...PANEL_SIZES.PANORAMIC,
152
+ gridColumn: GRID.FULL_WIDTH,
153
+ gridRow: "1",
154
+ acceptText: false,
155
+ }, // Wide panoramic top
156
+ {
157
+ ...PANEL_SIZES.COLUMN,
158
+ gridColumn: "1",
159
+ gridRow: "2 / span 2",
160
+ acceptText: false,
161
+ }, // Tall portrait left
162
  {
163
  ...PANEL_SIZES.POTRAIT,
164
  gridColumn: "2 / span 2",
165
  gridRow: "2 / span 2",
166
+ acceptText: true,
167
  }, // Large square right
168
  ],
169
  },
 
171
  gridCols: 1,
172
  gridRows: 2,
173
  panels: [
174
+ {
175
+ ...PANEL_SIZES.LANDSCAPE,
176
+ gridColumn: GRID.FULL_WIDTH,
177
+ gridRow: "1",
178
+ acceptText: true,
179
+ }, // Portrait top right
180
+ {
181
+ ...PANEL_SIZES.LANDSCAPE,
182
+ gridColumn: GRID.FULL_WIDTH,
183
+ gridRow: "2",
184
+ acceptText: true,
185
+ }, // Full width landscape bottom
186
  ],
187
  },
188
  };
 
196
  export const LAYOUTS_BY_PANEL_COUNT = {
197
  1: ["COVER"],
198
  2: ["LAYOUT_7"],
199
+ 3: ["LAYOUT_2", "LAYOUT_5"], //"LAYOUT_5"
200
+ 4: ["LAYOUT_3", "LAYOUT_4"], //, "LAYOUT_4"
201
  };
202
 
203
  // Helper functions for layout configuration
client/src/pages/Debug.jsx CHANGED
@@ -77,8 +77,10 @@ const Debug = () => {
77
  const initialHistoryEntry = {
78
  segment: response.story_text,
79
  player_choice: null,
 
80
  time: response.time,
81
  location: response.location,
 
82
  };
83
 
84
  setGameState({
@@ -105,12 +107,14 @@ const Debug = () => {
105
  const response = await storyApi.makeChoice(choiceIndex + 1, sessionId);
106
  setCurrentStory(response);
107
 
108
- // Construire l'entrée d'historique dans le même format que le serveur
109
  const historyEntry = {
110
  segment: response.story_text,
111
  player_choice: currentStory.choices[choiceIndex].text,
 
112
  time: response.time,
113
  location: response.location,
 
114
  };
115
 
116
  setGameState((prev) => ({
@@ -140,6 +144,70 @@ const Debug = () => {
140
  }
141
  }, [gameState?.story_history]);
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  if (error || sessionError) {
144
  return (
145
  <Box p={3}>
@@ -463,94 +531,9 @@ const Debug = () => {
463
  }}
464
  >
465
  {gameState.story_history.length > 0 ? (
466
- gameState.story_history.map((entry, idx) => (
467
- <Box
468
- key={idx}
469
- sx={{
470
- p: 1.5,
471
- borderBottom: 1,
472
- borderColor: "divider",
473
- "&:last-child": {
474
- borderBottom: 0,
475
- },
476
- backgroundColor:
477
- idx === gameState.story_history.length - 1
478
- ? "action.hover"
479
- : "inherit",
480
- }}
481
- >
482
- <Stack spacing={1}>
483
- {/* Story Text */}
484
- <Box
485
- sx={{
486
- backgroundColor: "background.paper",
487
- p: 1,
488
- borderRadius: 1,
489
- border: 1,
490
- borderColor: "divider",
491
- }}
492
- >
493
- <Typography variant="body2" color="text.primary">
494
- {entry.segment}
495
- </Typography>
496
- </Box>
497
-
498
- {/* Player Choice */}
499
- {entry.player_choice && (
500
- <Box
501
- sx={{
502
- display: "flex",
503
- alignItems: "center",
504
- gap: 0.5,
505
- backgroundColor: "action.selected",
506
- p: 1,
507
- borderRadius: 1,
508
- ml: 2,
509
- }}
510
- >
511
- <ArrowForwardIcon
512
- fontSize="small"
513
- sx={{ color: "primary.main" }}
514
- />
515
- <Typography
516
- variant="caption"
517
- sx={{
518
- color: "text.primary",
519
- fontWeight: "medium",
520
- }}
521
- >
522
- {entry.player_choice}
523
- </Typography>
524
- </Box>
525
- )}
526
-
527
- {/* Metadata */}
528
- <Stack
529
- direction="row"
530
- spacing={2}
531
- sx={{
532
- color: "text.secondary",
533
- mt: 0.5,
534
- "& > span": {
535
- display: "flex",
536
- alignItems: "center",
537
- gap: 0.5,
538
- fontSize: "0.75rem",
539
- },
540
- }}
541
- >
542
- <span>
543
- <TimerIcon fontSize="inherit" />
544
- {entry.time}
545
- </span>
546
- <span>
547
- <LocationIcon fontSize="inherit" />
548
- {entry.location}
549
- </span>
550
- </Stack>
551
- </Stack>
552
- </Box>
553
- ))
554
  ) : (
555
  <Box sx={{ p: 2, textAlign: "center" }}>
556
  <Typography variant="body2" color="text.secondary">
 
77
  const initialHistoryEntry = {
78
  segment: response.story_text,
79
  player_choice: null,
80
+ available_choices: response.choices.map((choice) => choice.text),
81
  time: response.time,
82
  location: response.location,
83
+ previous_choice: response.previous_choice,
84
  };
85
 
86
  setGameState({
 
107
  const response = await storyApi.makeChoice(choiceIndex + 1, sessionId);
108
  setCurrentStory(response);
109
 
110
+ // Construire l'entrée d'historique
111
  const historyEntry = {
112
  segment: response.story_text,
113
  player_choice: currentStory.choices[choiceIndex].text,
114
+ available_choices: currentStory.choices.map((choice) => choice.text),
115
  time: response.time,
116
  location: response.location,
117
+ previous_choice: response.previous_choice,
118
  };
119
 
120
  setGameState((prev) => ({
 
144
  }
145
  }, [gameState?.story_history]);
146
 
147
+ // Render history entries
148
+ const renderHistoryEntry = (entry, idx) => (
149
+ <Box
150
+ key={idx}
151
+ sx={{ mb: 2, p: 2, bgcolor: "background.paper", borderRadius: 1 }}
152
+ >
153
+ <Stack spacing={1}>
154
+ {/* Previous Choice (if any) */}
155
+ {entry.previous_choice && (
156
+ <Box
157
+ sx={{
158
+ display: "flex",
159
+ alignItems: "center",
160
+ gap: 1,
161
+ color: "text.secondary",
162
+ }}
163
+ >
164
+ <ArrowForwardIcon fontSize="small" />
165
+ <Typography variant="body2" sx={{ fontStyle: "italic" }}>
166
+ Choix précédent : {entry.previous_choice}
167
+ </Typography>
168
+ </Box>
169
+ )}
170
+
171
+ {/* Time and Location */}
172
+ <Box sx={{ display: "flex", gap: 2, color: "text.secondary" }}>
173
+ <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
174
+ <TimerIcon fontSize="small" />
175
+ <Typography variant="body2">{entry.time}</Typography>
176
+ </Box>
177
+ <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
178
+ <LocationIcon fontSize="small" />
179
+ <Typography variant="body2">{entry.location}</Typography>
180
+ </Box>
181
+ </Box>
182
+
183
+ {/* Story Text */}
184
+ <Typography>{entry.segment}</Typography>
185
+
186
+ {/* Available Choices */}
187
+ {entry.available_choices && entry.available_choices.length > 0 && (
188
+ <Box sx={{ mt: 1 }}>
189
+ <Typography variant="body2" color="text.secondary">
190
+ Choix disponibles :
191
+ </Typography>
192
+ <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
193
+ {entry.available_choices.map((choice, choiceIdx) => (
194
+ <Chip
195
+ key={choiceIdx}
196
+ label={choice}
197
+ size="small"
198
+ color={choice === entry.player_choice ? "primary" : "default"}
199
+ variant={
200
+ choice === entry.player_choice ? "filled" : "outlined"
201
+ }
202
+ />
203
+ ))}
204
+ </Box>
205
+ </Box>
206
+ )}
207
+ </Stack>
208
+ </Box>
209
+ );
210
+
211
  if (error || sessionError) {
212
  return (
213
  <Box p={3}>
 
531
  }}
532
  >
533
  {gameState.story_history.length > 0 ? (
534
+ gameState.story_history.map((entry, idx) =>
535
+ renderHistoryEntry(entry, idx)
536
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  ) : (
538
  <Box sx={{ p: 2, textAlign: "center" }}>
539
  <Typography variant="body2" color="text.secondary">
client/src/pages/Home.jsx CHANGED
@@ -43,9 +43,9 @@ export function Home() {
43
  justifyContent: "center",
44
  minHeight: "100vh",
45
  height: "100%",
46
- width: "100%",
 
47
  position: "relative",
48
- overflow: "hidden",
49
  }}
50
  >
51
  <Typography
@@ -82,12 +82,11 @@ export function Home() {
82
  </Typography>
83
 
84
  <Typography
85
- variant="body1"
86
  sx={{
87
  zIndex: 10,
88
  textAlign: "center",
89
  mt: 2,
90
- maxWidth: isMobile ? "80%" : "50%",
91
  opacity: 0.8,
92
  px: isMobile ? 2 : 0,
93
  fontSize: "clamp(0.875rem, 2vw, 1.125rem)",
 
43
  justifyContent: "center",
44
  minHeight: "100vh",
45
  height: "100%",
46
+ width: isMobile ? "80%" : "40%", // Adjust the width of the containing block
47
+ margin: "auto",
48
  position: "relative",
 
49
  }}
50
  >
51
  <Typography
 
82
  </Typography>
83
 
84
  <Typography
85
+ variant="caption"
86
  sx={{
87
  zIndex: 10,
88
  textAlign: "center",
89
  mt: 2,
 
90
  opacity: 0.8,
91
  px: isMobile ? 2 : 0,
92
  fontSize: "clamp(0.875rem, 2vw, 1.125rem)",
client/src/pages/Tutorial.jsx CHANGED
@@ -10,6 +10,8 @@ import { useSoundSystem } from "../contexts/SoundContext";
10
  import { motion } from "framer-motion";
11
  import { GameNavigation } from "../components/GameNavigation";
12
  import { StyledText } from "../components/StyledText";
 
 
13
 
14
  export function Tutorial() {
15
  const navigate = useNavigate();
@@ -44,7 +46,8 @@ export function Tutorial() {
44
  justifyContent: "center",
45
  minHeight: "100vh",
46
  height: "100%",
47
- width: "100%",
 
48
  position: "relative",
49
  overflow: "hidden",
50
  }}
@@ -84,11 +87,73 @@ export function Tutorial() {
84
  for each playthrough.
85
  <br />
86
  <br />
87
- At every stage of the narrative, you will be presented with choices or
88
- the opportunity to write the next part of the story yourself.
89
  <br />
90
  <br />
91
- Think of it more as a lucid dream than a traditional game. Enjoy!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  </Typography>
93
 
94
  <Button
 
10
  import { motion } from "framer-motion";
11
  import { GameNavigation } from "../components/GameNavigation";
12
  import { StyledText } from "../components/StyledText";
13
+ import MouseOutlinedIcon from "@mui/icons-material/MouseOutlined";
14
+ import CreateOutlinedIcon from "@mui/icons-material/CreateOutlined";
15
 
16
  export function Tutorial() {
17
  const navigate = useNavigate();
 
46
  justifyContent: "center",
47
  minHeight: "100vh",
48
  height: "100%",
49
+ width: isMobile ? "90%" : "70%",
50
+ margin: "auto",
51
  position: "relative",
52
  overflow: "hidden",
53
  }}
 
87
  for each playthrough.
88
  <br />
89
  <br />
90
+ At each step you can decide to
 
91
  <br />
92
  <br />
93
+ <Box
94
+ sx={{
95
+ display: "flex",
96
+ gap: 4,
97
+ justifyContent: "center",
98
+ mb: 2,
99
+ alignItems: "center",
100
+ }}
101
+ >
102
+ <Box
103
+ sx={{
104
+ border: "1px solid rgba(255,255,255,0.3)",
105
+ borderRadius: "8px",
106
+ p: 2,
107
+ flex: 1,
108
+ maxWidth: "200px",
109
+ display: "flex",
110
+ flexDirection: "column",
111
+ alignItems: "center",
112
+ gap: 1,
113
+ backdropFilter: "blur(20px)",
114
+ backgroundColor: "rgba(255,255,255,0.05)",
115
+ }}
116
+ >
117
+ <MouseOutlinedIcon
118
+ sx={{ fontSize: 40, color: "primary.main", mb: 1 }}
119
+ />
120
+ <Typography variant="subtitle1" sx={{ color: "primary.main" }}>
121
+ Make a choice
122
+ </Typography>
123
+ </Box>
124
+ <Typography
125
+ variant="h6"
126
+ sx={{
127
+ color: "rgba(255,255,255,0.5)",
128
+ fontWeight: "bold",
129
+ }}
130
+ >
131
+ OR
132
+ </Typography>
133
+ <Box
134
+ sx={{
135
+ border: "1px solid rgba(255,255,255,0.3)",
136
+ borderRadius: "8px",
137
+ p: 2,
138
+ flex: 1,
139
+ maxWidth: "200px",
140
+ display: "flex",
141
+ flexDirection: "column",
142
+ alignItems: "center",
143
+ gap: 1,
144
+ backdropFilter: "blur(20px)",
145
+ backgroundColor: "rgba(255,255,255,0.05)",
146
+ }}
147
+ >
148
+ <CreateOutlinedIcon
149
+ sx={{ fontSize: 40, color: "primary.main", mb: 1 }}
150
+ />
151
+ <Typography variant="subtitle1" sx={{ color: "primary.main" }}>
152
+ Write your own
153
+ </Typography>
154
+ </Box>
155
+ </Box>
156
+ Until the end of the game
157
  </Typography>
158
 
159
  <Button
server/api/models.py CHANGED
@@ -62,6 +62,7 @@ class UniverseResponse(BaseModel):
62
 
63
  # Complete story response combining all parts - preserved for API compatibility
64
  class StoryResponse(BaseModel):
 
65
  story_text: str = Field(description="The story text. No more than 15 words THIS IS MANDATORY. Never mention story beat directly. ")
66
  choices: List[Choice]
67
  raw_choices: List[str] = Field(description="Raw choice texts from LLM before conversion to Choice objects")
 
62
 
63
  # Complete story response combining all parts - preserved for API compatibility
64
  class StoryResponse(BaseModel):
65
+ previous_choice: str = Field(description="The previous choice made by the player")
66
  story_text: str = Field(description="The story text. No more than 15 words THIS IS MANDATORY. Never mention story beat directly. ")
67
  choices: List[Choice]
68
  raw_choices: List[str] = Field(description="Raw choice texts from LLM before conversion to Choice objects")
server/api/routes/chat.py CHANGED
@@ -57,49 +57,45 @@ def get_chat_router(session_manager: SessionManager, story_generator):
57
  # Pour les choix personnalisés, on les traite immédiatement
58
  if chat_message.message == "custom_choice" and chat_message.custom_text:
59
  previous_choice = chat_message.custom_text
60
- # On ajoute le choix à l'historique avant de générer le segment
61
- game_state.add_to_history(
62
- f"You decide to: {chat_message.custom_text}",
63
- previous_choice,
64
- [], # pas d'image pour le choix
65
- game_state.current_time,
66
- game_state.current_location
 
 
 
 
 
 
 
 
67
  )
 
68
  else:
69
- previous_choice = f"Choice {chat_message.choice_id}" if chat_message.choice_id else "none"
 
 
 
 
 
 
 
 
 
70
 
71
  # Generate story segment
72
- llm_response = await story_generator.generate_story_segment(
73
  session_id=x_session_id,
74
  game_state=game_state,
75
  previous_choice=previous_choice
76
  )
77
 
78
- # Add segment to history
79
- game_state.add_to_history(
80
- llm_response.story_text,
81
- previous_choice,
82
- llm_response.image_prompts,
83
- llm_response.time,
84
- llm_response.location
85
- )
86
-
87
  # Pour la première étape, on ne garde qu'un seul prompt d'image
88
- if game_state.story_beat == 0 and len(llm_response.image_prompts) > 1:
89
- llm_response.image_prompts = [llm_response.image_prompts[0]]
90
-
91
- # Prepare response
92
- response = StoryResponse(
93
- story_text=llm_response.story_text,
94
- choices=llm_response.choices,
95
- raw_choices=llm_response.raw_choices,
96
- time=llm_response.time,
97
- location=llm_response.location,
98
- is_first_step=game_state.story_beat == 0,
99
- image_prompts=llm_response.image_prompts,
100
- is_death=llm_response.is_death,
101
- is_victory=llm_response.is_victory
102
- )
103
 
104
  # Increment story beat
105
  game_state.story_beat += 1
 
57
  # Pour les choix personnalisés, on les traite immédiatement
58
  if chat_message.message == "custom_choice" and chat_message.custom_text:
59
  previous_choice = chat_message.custom_text
60
+ # On crée un StoryResponse pour le choix personnalisé
61
+ custom_choice_response = StoryResponse(
62
+ story_text=f"You decide to: {chat_message.custom_text}",
63
+ choices=[
64
+ Choice(id=1, text="Continue..."), # Choix fictif pour validation
65
+ Choice(id=2, text="Continue...")
66
+ ],
67
+ raw_choices=["Continue...", "Continue..."],
68
+ time=game_state.current_time,
69
+ location=game_state.current_location,
70
+ image_prompts=["Character making a custom choice"], # Prompt fictif pour validation
71
+ is_first_step=False,
72
+ is_death=False,
73
+ is_victory=False,
74
+ previous_choice=previous_choice
75
  )
76
+ game_state.add_to_history(custom_choice_response)
77
  else:
78
+ # Si un choix a été fait, récupérer le texte du choix à partir de l'historique
79
+ if chat_message.choice_id and len(game_state.story_history) > 0:
80
+ last_story = game_state.story_history[-1]
81
+ choice_index = chat_message.choice_id - 1
82
+ if 0 <= choice_index < len(last_story.choices):
83
+ previous_choice = last_story.choices[choice_index].text
84
+ else:
85
+ previous_choice = "none"
86
+ else:
87
+ previous_choice = "none"
88
 
89
  # Generate story segment
90
+ response = await story_generator.generate_story_segment(
91
  session_id=x_session_id,
92
  game_state=game_state,
93
  previous_choice=previous_choice
94
  )
95
 
 
 
 
 
 
 
 
 
 
96
  # Pour la première étape, on ne garde qu'un seul prompt d'image
97
+ if game_state.story_beat == 0 and len(response.image_prompts) > 1:
98
+ response.image_prompts = [response.image_prompts[0]]
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  # Increment story beat
101
  game_state.story_beat += 1
server/core/game_state.py CHANGED
@@ -1,10 +1,11 @@
1
  from core.constants import GameConfig
2
  from typing import List
 
3
 
4
  class GameState:
5
  def __init__(self):
6
  self.story_beat = GameConfig.STORY_BEAT_INTRO
7
- self.story_history = []
8
  self.current_time = GameConfig.STARTING_TIME
9
  self.current_location = GameConfig.STARTING_LOCATION
10
  # Universe information
@@ -50,27 +51,42 @@ class GameState:
50
  ])
51
 
52
  def format_history(self) -> str:
53
- """Format story history for the prompt."""
 
54
  if not self.story_history:
55
  return ""
56
 
 
 
 
57
  segments = []
58
- for entry in self.story_history:
59
- segment = entry['segment']
60
- if entry['player_choice']:
61
- segment += f"\n[Choix du joueur: {entry['player_choice']}]"
62
- segments.append(segment)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  return "\n\n---\n\n".join(segments)
65
 
66
- def add_to_history(self, segment_text: str, choice_made: str, image_prompts: List[str], time: str, location: str):
67
- """Add a segment to history with essential information."""
68
- self.story_history.append({
69
- "segment": segment_text,
70
- "player_choice": choice_made,
71
- "time": time,
72
- "location": location,
73
- "image_prompts": image_prompts
74
- })
75
- self.current_time = time
76
- self.current_location = location
 
1
  from core.constants import GameConfig
2
  from typing import List
3
+ from api.models import StoryResponse
4
 
5
  class GameState:
6
  def __init__(self):
7
  self.story_beat = GameConfig.STORY_BEAT_INTRO
8
+ self.story_history: List[StoryResponse] = []
9
  self.current_time = GameConfig.STARTING_TIME
10
  self.current_location = GameConfig.STARTING_LOCATION
11
  # Universe information
 
51
  ])
52
 
53
  def format_history(self) -> str:
54
+ """Format story history for the prompt.
55
+ Returns only the last 4 segments of the story (or less if not available)."""
56
  if not self.story_history:
57
  return ""
58
 
59
+ # Ne prendre que les 3 derniers segments
60
+ last_segments = self.story_history[-4:] if len(self.story_history) > 4 else self.story_history
61
+
62
  segments = []
63
+ for story_response in last_segments:
64
+ # Commencer par le choix précédent s'il existe
65
+ segment_parts = []
66
+ if story_response.previous_choice and story_response.previous_choice != "none":
67
+ segment_parts.append(f"[Previous choice: {story_response.previous_choice}]")
68
+
69
+ # Ajouter le texte de l'histoire
70
+ segment_parts.append(story_response.story_text)
71
+
72
+ # Ajouter les choix disponibles s'ils existent
73
+ if story_response.choices:
74
+ choices_text = "\nAvailable choices were:"
75
+ for choice in story_response.choices:
76
+ choices_text += f"\n- {choice.text}"
77
+ segment_parts.append(choices_text)
78
+
79
+ # Joindre toutes les parties avec des sauts de ligne
80
+ segments.append("\n".join(segment_parts))
81
+
82
+ # Ajouter une indication si on a tronqué l'historique
83
+ if len(self.story_history) > 4:
84
+ segments.insert(0, f"[...{len(self.story_history) - 4} earlier segments omitted...]")
85
 
86
  return "\n\n---\n\n".join(segments)
87
 
88
+ def add_to_history(self, story_response: StoryResponse):
89
+ """Add a story response to history."""
90
+ self.story_history.append(story_response)
91
+ self.current_time = story_response.time
92
+ self.current_location = story_response.location
 
 
 
 
 
 
server/core/generators/image_prompt_generator.py CHANGED
@@ -2,7 +2,7 @@ from typing import List
2
  from pydantic import BaseModel, Field
3
  from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate
4
  import json
5
-
6
  from core.generators.base_generator import BaseGenerator
7
 
8
  class ImagePromptResponse(BaseModel):
@@ -67,25 +67,21 @@ class ImagePromptGenerator(BaseGenerator):
67
  "[shot type] [scene description]"
68
 
69
  EXAMPLES:
70
- - "low angle shot of a mysterious figure checking an object in a dark corridor"
71
- - "wide shot of a ruined cityscape at sunset, silhouette of a lone traveler in the foreground"
72
- - "Dutch angle close-up of a determined face illuminated by the glow of an object"
73
- - "over shoulder shot of a character looking at an ancient map spread out on a table"
74
- - "close-up of eyes reflecting the flames of a nearby fire"
75
- - "wide shot of a dense forest with a figure barely visible among the trees"
76
- - "high angle shot of a character standing at the edge of a cliff, looking down at a vast ocean"
77
- - "medium shot of a person walking through a bustling marketplace, with various vendors and colorful stalls"
78
- - "low angle shot of a character standing in front of a towering ancient statue, looking up in awe"
79
- - "close-up of fingers tracing the carvings on an ancient artifact"
80
- - "wide shot of a stormy sky with lightning illuminating a determined silhouette"
81
- - "close-up of an ancient compass, its needle spinning wildly"
82
- - "over shoulder shot of a mysterious figure watching from the shadows"
83
- - "medium shot of a group of travelers gathered around a campfire, sharing stories"
84
- - "Dutch angle shot of a clock tower striking midnight, casting long shadows"
85
- - "close-up of a hand gripping a sword hilt, ready for battle"
86
- - "wide shot of a bustling port with ships coming and going, seagulls circling above"
87
- - "high angle shot of a chessboard mid-game, pieces scattered in strategic positions"
88
- - "medium shot of two characters in a heated argument, tension visible in their expressions"
89
 
90
  Always maintain consistency with {self.hero_name}'s appearance and the style.
91
 
@@ -122,11 +118,13 @@ Story text: {story_text}
122
  Generate panel descriptions that capture the key moments of this scene.
123
  do not have panels that look alike, each successive panel must be different,
124
  and explain the story like a storyboard.
 
125
 
126
  Dont put the hero name every time.
127
- Exactly between 1 and 4 panels. (mostly 2 or 3)
128
 
129
  {is_end}
 
130
  """
131
 
132
  return ChatPromptTemplate(
@@ -230,7 +228,14 @@ Exactly between 1 and 4 panels. (mostly 2 or 3)
230
  ImagePromptResponse containing the generated and formatted image prompts
231
  """
232
 
233
- is_end="Must have between 2 and 4 prompts, MANDATORY."
 
 
 
 
 
 
 
234
  if is_death:
235
  is_end = f"This is the death of {self.hero_name}. just one panel, MANDATORY."
236
  elif is_victory:
@@ -243,6 +248,7 @@ Exactly between 1 and 4 panels. (mostly 2 or 3)
243
  is_death=is_death,
244
  is_victory=is_victory,
245
  is_end=is_end,
 
246
  )
247
 
248
  # Format each prompt with metadata
 
2
  from pydantic import BaseModel, Field
3
  from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate
4
  import json
5
+ import random
6
  from core.generators.base_generator import BaseGenerator
7
 
8
  class ImagePromptResponse(BaseModel):
 
67
  "[shot type] [scene description]"
68
 
69
  EXAMPLES:
70
+ - "medium shot of a bustling marketplace, vibrant colors and lively chatter"
71
+ - "close-up of a mysterious figure's eyes, reflecting a hidden agenda"
72
+ - "wide shot of a serene lake at dawn, mist rising from the water"
73
+ - "Dutch angle of a character sprinting through a narrow alley, urgency in every step"
74
+ - "over shoulder shot of a child peering into a forbidden book, curiosity in their eyes"
75
+ - "low angle shot of a towering skyscraper, clouds swirling around its peak"
76
+ - "high angle shot of a chessboard mid-game, pieces poised for a decisive move"
77
+ - "close-up of a hand reaching for a glowing orb, anticipation in the air"
78
+ - "wide shot of a desert landscape, a lone figure trudging through the sand"
79
+ - "medium shot of a character standing in a rain-soaked street, determination etched on their face"
80
+ - "Dutch angle of a clock tower striking midnight, shadows stretching across the square"
81
+ - "over shoulder shot of a detective examining a crime scene, clues scattered around"
82
+ - "close-up of a flower blooming in a crack in the pavement, symbolizing hope"
83
+ - "wide shot of a stormy sea, waves crashing against a rocky shore"
84
+ - "medium shot of a group of friends laughing around a campfire, warmth in their expressions"
 
 
 
 
85
 
86
  Always maintain consistency with {self.hero_name}'s appearance and the style.
87
 
 
118
  Generate panel descriptions that capture the key moments of this scene.
119
  do not have panels that look alike, each successive panel must be different,
120
  and explain the story like a storyboard.
121
+ SHOW, DONT TELL. DESCRIBE THE PANELS, be specific, put names on things.
122
 
123
  Dont put the hero name every time.
124
+ {how_many_panels} panels
125
 
126
  {is_end}
127
+
128
  """
129
 
130
  return ChatPromptTemplate(
 
228
  ImagePromptResponse containing the generated and formatted image prompts
229
  """
230
 
231
+ how_many_panels = 2
232
+ # Générer un nombre aléatoire de panneaux entre 1 et 4
233
+ if is_death or is_victory:
234
+ how_many_panels = 1
235
+ else:
236
+ how_many_panels = random.choices([1, 2, 3, 4], weights=[0.05, 0.3, 0.4, 0.25], k=1)[0]
237
+
238
+ is_end=""
239
  if is_death:
240
  is_end = f"This is the death of {self.hero_name}. just one panel, MANDATORY."
241
  elif is_victory:
 
248
  is_death=is_death,
249
  is_victory=is_victory,
250
  is_end=is_end,
251
+ how_many_panels=how_many_panels,
252
  )
253
 
254
  # Format each prompt with metadata
server/core/generators/metadata_generator.py CHANGED
@@ -35,44 +35,50 @@ class MetadataGenerator(BaseGenerator):
35
  You must return a JSON object with the following format:
36
  {{{{
37
  "is_death": false, # Set to true for death scenes
38
- "is_victory": false # Set to true for victory scenes
39
- "choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
40
  "time": "HH:MM",
41
  "location": "Location",
 
42
  }}}}
43
  """
44
 
45
  human_template = """
46
 
47
- History:
48
- {story_history}
49
-
50
- Current story segment:
51
- {story_text}
52
-
53
- - Current time: {current_time}
54
- - Current location: {current_location}
55
 
56
 
57
  FOR CHOICES : NEVER propose to go back to the previous location or go back to the portal. NEVER.
58
  Dont be obvious. NEVER use "approach the ...", its too slow to be a choice.
59
 
60
- {is_end}
61
 
62
  You can be original in your choices, but dont be too far from the story.
63
  Dont be too cliché. The choices should be realistically different.
64
  The choices should be the direct continuation of the story.
65
  The choices should be the direct continuation of the story.
66
  The choices should be the direct continuation of the story.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
  - Each choice MUST be NO MORE than 6 words - this is a HARD limit
69
  You must return a JSON object with the following format:
70
  {{{{
71
  "is_death": false, # Set to true for death scenes
72
- "is_victory": false # Set to true for victory scenes
73
- "choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
74
  "time": "HH:MM",
75
  "location": "Location name",
 
76
  }}}}
77
 
78
  """
@@ -87,45 +93,76 @@ You must return a JSON object with the following format:
87
 
88
  def _validate_choices(self, choices) -> bool:
89
  """Valide que les choix respectent les règles."""
90
- if not isinstance(choices, list) or len(choices) != 2:
91
  return False
92
 
 
 
 
 
 
93
  for choice in choices:
94
- # Vérifier la longueur des mots
95
- word_count = len(choice.split())
96
- if word_count > 6:
97
  return False
98
-
99
- # Vérifier que le choix n'est pas vide
100
- if not choice.strip():
101
  return False
102
-
103
- # Vérifier que les choix ne contiennent pas de mots interdits
104
- forbidden_words = ["back", "return", "portal"]
105
- if any(word.lower() in choice.lower() for word in forbidden_words):
106
  return False
107
-
108
- # Vérifier que les choix sont différents
109
- if choices[0].lower() == choices[1].lower():
110
- return False
111
 
112
  return True
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  async def generate(self, story_text: str, current_time: str, current_location: str, story_beat: int, error_feedback: str = "", turn_before_end: int = 0, is_winning_story: bool = False, story_history: str = "") -> StoryMetadataResponse:
115
  """Surcharge de generate pour inclure le error_feedback par défaut."""
116
 
117
  is_end = "This IS the end of the story." if story_beat == turn_before_end else ""
118
  retry_count = 0
119
  last_error = None
 
120
 
121
  while retry_count < self.max_retries:
122
  try:
 
 
 
123
  response = await super().generate(
124
  story_text=story_text,
125
  current_time=current_time,
126
  current_location=current_location,
127
  story_beat=story_beat,
128
- error_feedback=error_feedback,
129
  is_end=is_end,
130
  turn_before_end=turn_before_end,
131
  is_winning_story=is_winning_story,
@@ -133,19 +170,16 @@ You must return a JSON object with the following format:
133
  )
134
 
135
  print(f"[MetadataGenerator] Raw response before validation (attempt {retry_count + 1}):", response)
136
- print(f"[MetadataGenerator] Choices:", response.choices)
137
- print(f"[MetadataGenerator] Time:", response.time)
138
- print(f"[MetadataGenerator] Location:", response.location)
139
-
140
  # Valider les choix
141
  if self._validate_choices(response.choices):
142
  print("[MetadataGenerator] Validation successful!")
143
  return response
144
 
145
  print(f"[MetadataGenerator] Validation failed for choices:", response.choices)
146
- # Si les choix ne sont pas valides, ajouter un feedback et réessayer
 
147
  retry_count += 1
148
- error_feedback = f"Previous choices were invalid. Remember: EXACTLY 2 choices, MAX 6 words each, must be different and relevant. Last attempt: {response.choices}"
149
  continue
150
 
151
  except Exception as e:
@@ -155,7 +189,6 @@ You must return a JSON object with the following format:
155
  if retry_count >= self.max_retries:
156
  print(f"[MetadataGenerator] Failed to generate valid metadata after {self.max_retries} attempts. Last error: {str(e)}")
157
  raise e
158
- error_feedback = f"Error in previous attempt: {str(e)}. Please try again with valid format."
159
  continue
160
 
161
  # Si on arrive ici, c'est qu'on a épuisé toutes les tentatives
@@ -178,14 +211,16 @@ You must return a JSON object with the following format:
178
  print("[MetadataGenerator] First cleaning failed:", str(e1))
179
  # Deuxième tentative : supprimer les commentaires et les espaces superflus
180
  import re
181
- # Supprimer les commentaires
182
  cleaned_content = re.sub(r'#.*$', '', cleaned_content, flags=re.MULTILINE)
183
- # Supprimer les espaces superflus
184
  cleaned_content = re.sub(r'\s+', ' ', cleaned_content)
185
  print("[MetadataGenerator] Second cleaning attempt:", cleaned_content)
186
- data = json.loads(cleaned_content)
187
- print("[MetadataGenerator] Successfully parsed JSON after second cleaning")
188
-
 
 
 
 
189
  # Vérifier que les choix sont valides selon les règles
190
  choices = data.get('choices', [])
191
  print("[MetadataGenerator] Extracted choices:", choices)
@@ -208,4 +243,4 @@ You must return a JSON object with the following format:
208
  except Exception as e:
209
  print("[MetadataGenerator] Final error:", str(e))
210
  print("[MetadataGenerator] Failed to parse response content")
211
- raise ValueError('Invalid JSON format. Must have EXACTLY two choices. Please provide a valid JSON object.')
 
35
  You must return a JSON object with the following format:
36
  {{{{
37
  "is_death": false, # Set to true for death scenes
38
+ "is_victory": false, # Set to true for victory scenes
 
39
  "time": "HH:MM",
40
  "location": "Location",
41
+ "choices": ["Choice 1", "Choice 2"] # ALWAYS exactly two choices, each max 6 words
42
  }}}}
43
  """
44
 
45
  human_template = """
46
 
 
 
 
 
 
 
 
 
47
 
48
 
49
  FOR CHOICES : NEVER propose to go back to the previous location or go back to the portal. NEVER.
50
  Dont be obvious. NEVER use "approach the ...", its too slow to be a choice.
51
 
 
52
 
53
  You can be original in your choices, but dont be too far from the story.
54
  Dont be too cliché. The choices should be realistically different.
55
  The choices should be the direct continuation of the story.
56
  The choices should be the direct continuation of the story.
57
  The choices should be the direct continuation of the story.
58
+ The choice not have to be the most obvious one. or even the most logical one.
59
+
60
+ History:
61
+ {story_history}
62
+
63
+ Current story segment:
64
+ {story_text}
65
+
66
+ - Current time: {current_time}
67
+ - Current location: {current_location}
68
+
69
+ {is_end}
70
+
71
+ The choice not have to be the most obvious one. or even the most logical one.
72
+ It MUST have a relation to the context of the story but it can be a choice that doesn't make sense.
73
 
74
  - Each choice MUST be NO MORE than 6 words - this is a HARD limit
75
  You must return a JSON object with the following format:
76
  {{{{
77
  "is_death": false, # Set to true for death scenes
78
+ "is_victory": false, # Set to true for victory scenes
 
79
  "time": "HH:MM",
80
  "location": "Location name",
81
+ "choices": ["Choice 1", "Choice 2"] # ALWAYS exactly two choices, each max 6 words
82
  }}}}
83
 
84
  """
 
93
 
94
  def _validate_choices(self, choices) -> bool:
95
  """Valide que les choix respectent les règles."""
96
+ if not isinstance(choices, list):
97
  return False
98
 
99
+ if len(choices) != 2:
100
+ return False
101
+
102
+ # Vérifier que les choix sont différents et pas trop longs
103
+ seen_choices = set()
104
  for choice in choices:
105
+ if not isinstance(choice, str):
 
 
106
  return False
107
+ if len(choice.split()) > 6: # Max 6 mots
 
 
108
  return False
109
+ if choice.lower() in seen_choices:
 
 
 
110
  return False
111
+ seen_choices.add(choice.lower())
 
 
 
112
 
113
  return True
114
 
115
+ def _get_error_feedback(self, error, response=None) -> str:
116
+ """Génère un feedback spécifique basé sur le type d'erreur."""
117
+ if isinstance(error, json.JSONDecodeError):
118
+ return "Your response must be a valid JSON object. Please ensure proper JSON formatting."
119
+
120
+ if "choices" in str(error).lower():
121
+ choices = response.choices if response and hasattr(response, 'choices') else []
122
+ issues = []
123
+
124
+ if not isinstance(choices, list):
125
+ return "The 'choices' field must be a list containing exactly 2 choices."
126
+
127
+ if len(choices) != 2:
128
+ issues.append(f"Found {len(choices)} choices, need exactly 2")
129
+
130
+ seen = set()
131
+ for choice in choices:
132
+ if not isinstance(choice, str):
133
+ issues.append("All choices must be strings")
134
+ elif len(choice.split()) > 6:
135
+ issues.append(f"Choice '{choice}' is too long (max 6 words)")
136
+ elif choice.lower() in seen:
137
+ issues.append(f"Choice '{choice}' is duplicated")
138
+ seen.add(choice.lower())
139
+
140
+ return "Choice validation failed: " + ", ".join(issues)
141
+
142
+ if "missing" in str(error).lower():
143
+ return "Missing required fields in response. Please include: is_death, is_victory, choices, time, location"
144
+
145
+ return str(error)
146
+
147
  async def generate(self, story_text: str, current_time: str, current_location: str, story_beat: int, error_feedback: str = "", turn_before_end: int = 0, is_winning_story: bool = False, story_history: str = "") -> StoryMetadataResponse:
148
  """Surcharge de generate pour inclure le error_feedback par défaut."""
149
 
150
  is_end = "This IS the end of the story." if story_beat == turn_before_end else ""
151
  retry_count = 0
152
  last_error = None
153
+ last_response = None
154
 
155
  while retry_count < self.max_retries:
156
  try:
157
+ # Si on a un feedback d'erreur précédent, l'utiliser
158
+ current_feedback = self._get_error_feedback(last_error, last_response) if last_error else error_feedback
159
+
160
  response = await super().generate(
161
  story_text=story_text,
162
  current_time=current_time,
163
  current_location=current_location,
164
  story_beat=story_beat,
165
+ error_feedback=current_feedback,
166
  is_end=is_end,
167
  turn_before_end=turn_before_end,
168
  is_winning_story=is_winning_story,
 
170
  )
171
 
172
  print(f"[MetadataGenerator] Raw response before validation (attempt {retry_count + 1}):", response)
173
+
 
 
 
174
  # Valider les choix
175
  if self._validate_choices(response.choices):
176
  print("[MetadataGenerator] Validation successful!")
177
  return response
178
 
179
  print(f"[MetadataGenerator] Validation failed for choices:", response.choices)
180
+ last_response = response
181
+ last_error = ValueError("Invalid choices format")
182
  retry_count += 1
 
183
  continue
184
 
185
  except Exception as e:
 
189
  if retry_count >= self.max_retries:
190
  print(f"[MetadataGenerator] Failed to generate valid metadata after {self.max_retries} attempts. Last error: {str(e)}")
191
  raise e
 
192
  continue
193
 
194
  # Si on arrive ici, c'est qu'on a épuisé toutes les tentatives
 
211
  print("[MetadataGenerator] First cleaning failed:", str(e1))
212
  # Deuxième tentative : supprimer les commentaires et les espaces superflus
213
  import re
 
214
  cleaned_content = re.sub(r'#.*$', '', cleaned_content, flags=re.MULTILINE)
 
215
  cleaned_content = re.sub(r'\s+', ' ', cleaned_content)
216
  print("[MetadataGenerator] Second cleaning attempt:", cleaned_content)
217
+ try:
218
+ data = json.loads(cleaned_content)
219
+ print("[MetadataGenerator] Successfully parsed JSON after second cleaning")
220
+ except json.JSONDecodeError as e2:
221
+ print("[MetadataGenerator] Second cleaning failed:", str(e2))
222
+ raise ValueError("Failed to parse JSON after multiple cleaning attempts")
223
+
224
  # Vérifier que les choix sont valides selon les règles
225
  choices = data.get('choices', [])
226
  print("[MetadataGenerator] Extracted choices:", choices)
 
243
  except Exception as e:
244
  print("[MetadataGenerator] Final error:", str(e))
245
  print("[MetadataGenerator] Failed to parse response content")
246
+ raise ValueError(str(e))
server/core/generators/story_segment_generator.py CHANGED
@@ -60,7 +60,7 @@ Base Story:
60
 
61
  Your task is to generate the next segment of the story, following these rules:
62
  1. Keep the story consistent with the universe parameters
63
- 2. Each segment must advance the plot
64
  3. Never repeat previous descriptions or situations
65
  4. Keep segments concise and impactful
66
 
@@ -73,12 +73,14 @@ Hero Description: {self.hero_desc}
73
  Story history:
74
  {story_history}
75
 
76
-
77
  Never describes game variables.
78
 
79
  IT MUST BE THE DIRECT CONTINUATION OF THE CURRENT STORY.
80
  You MUST mention the previous situation and what is happening now with the new choice.
81
  Never propose choices or options. Never describe the game variables.
 
 
 
82
  LIMIT: 15 words.
83
  """
84
  return ChatPromptTemplate(
@@ -155,7 +157,9 @@ LIMIT: 15 words.
155
  is_death = True if is_end and is_winning_story else False
156
  is_victory = True if is_end and not is_winning_story else False
157
 
158
- what_to_represent = self._get_what_to_represent(story_beat, is_death, is_victory)
 
 
159
 
160
  # Si c'est un choix personnalisé, on l'utilise comme contexte pour générer la suite
161
  if previous_choice and not previous_choice.startswith("Choice "):
@@ -163,13 +167,13 @@ LIMIT: 15 words.
163
  Based on the player's custom choice: "{previous_choice}"
164
 
165
  Write a story segment that:
166
- 1. Directly follows and incorporates the player's choice
167
- 2. Maintains consistency with the universe and story
168
- 3. Respects all previous rules about length and style
169
- 4. Naturally integrates the custom elements while staying true to the plot
170
- 5. NEVER FORGET THE CHOICE, IT MUST BE MENTIONED IN THE STORY.
171
- 6. Start with a direct reaction to the player's choice
172
- 7. Show immediate consequences of their action
173
  """
174
 
175
  # Créer les messages
 
60
 
61
  Your task is to generate the next segment of the story, following these rules:
62
  1. Keep the story consistent with the universe parameters
63
+ 2. Each segment must go forward in the story
64
  3. Never repeat previous descriptions or situations
65
  4. Keep segments concise and impactful
66
 
 
73
  Story history:
74
  {story_history}
75
 
 
76
  Never describes game variables.
77
 
78
  IT MUST BE THE DIRECT CONTINUATION OF THE CURRENT STORY.
79
  You MUST mention the previous situation and what is happening now with the new choice.
80
  Never propose choices or options. Never describe the game variables.
81
+ The world is a dangerous place. The hero is in GREAT danger. he has great risk to die.
82
+ If you fail a big battle, the hero is dead.
83
+
84
  LIMIT: 15 words.
85
  """
86
  return ChatPromptTemplate(
 
157
  is_death = True if is_end and is_winning_story else False
158
  is_victory = True if is_end and not is_winning_story else False
159
 
160
+ what_to_represent = ""
161
+ # what_to_represent = self._get_what_to_represent(story_beat, is_death, is_victory)
162
+ #
163
 
164
  # Si c'est un choix personnalisé, on l'utilise comme contexte pour générer la suite
165
  if previous_choice and not previous_choice.startswith("Choice "):
 
167
  Based on the player's custom choice: "{previous_choice}"
168
 
169
  Write a story segment that:
170
+ 1. Respects all previous rules about length and style
171
+ 2. Ff you find a path to go in a special place, you have to travel there.
172
+ 3. Directly follows and incorporates the player's choice
173
+ 4. Maintains consistency with the universe and story
174
+ 5. Naturally integrates the custom elements while staying true to the plot
175
+
176
+ MANDATORY : Start with a direct reaction to the player's choice, Show immediate consequences of their action. Then go forward in the story.
177
  """
178
 
179
  # Créer les messages
server/core/generators/universe_generator.py CHANGED
@@ -24,7 +24,7 @@ class UniverseGenerator(BaseGenerator):
24
  - Genre: {genre}
25
  - Historical epoch: {epoch}
26
 
27
- Describe the first segment of the story. in 20 words. Where is the main character, what is he doing? HE has to do something banal. You have to describe the first action.
28
  """
29
 
30
  return ChatPromptTemplate(
 
24
  - Genre: {genre}
25
  - Historical epoch: {epoch}
26
 
27
+ Describe the first segment of the story. in 30 words. Where is the main character, what is he doing? HE has to do something banal. You have to describe the first action.
28
  """
29
 
30
  return ChatPromptTemplate(
server/core/story_generator.py CHANGED
@@ -49,7 +49,7 @@ class StoryGenerator:
49
  artist = style["references"][0]["artist"]
50
 
51
  # Create a detailed artist style string
52
- artist_style = f"{artist}, {style['name']} style, {genre} in {epoch}"
53
 
54
  # Always create a new ImagePromptGenerator for each session with the correct artist and hero
55
  self.image_prompt_generator = ImagePromptGenerator(
@@ -128,7 +128,6 @@ class StoryGenerator:
128
  is_winning_story=self.is_winning_story,
129
  story_history=game_state.format_history()
130
  )
131
- # print(f"Generated metadata_response: {metadata_response}")
132
 
133
  # Generate image prompts
134
  prompts_response = await self.image_prompt_generator.generate(
@@ -140,7 +139,6 @@ class StoryGenerator:
140
  turn_before_end=self.turn_before_end,
141
  is_winning_story=self.is_winning_story
142
  )
143
- # print(f"Generated image prompts: {prompts_response}")
144
 
145
  # Create choices
146
  choices = [
@@ -151,16 +149,21 @@ class StoryGenerator:
151
  response = StoryResponse(
152
  story_text=story_text,
153
  choices=choices,
 
154
  time=metadata_response.time,
155
  location=metadata_response.location,
156
- raw_choices=metadata_response.choices,
157
  image_prompts=prompts_response.image_prompts,
158
  is_first_step=(game_state.story_beat == GameConfig.STORY_BEAT_INTRO),
159
  is_death=metadata_response.is_death,
160
- is_victory=metadata_response.is_victory
 
161
  )
162
 
163
- return response
 
 
 
 
164
  except Exception as e:
165
  print(f"Unexpected error in generate_story_segment: {str(e)}")
166
  raise
 
49
  artist = style["references"][0]["artist"]
50
 
51
  # Create a detailed artist style string
52
+ artist_style = f"{style['name']}, {genre} in {epoch}"
53
 
54
  # Always create a new ImagePromptGenerator for each session with the correct artist and hero
55
  self.image_prompt_generator = ImagePromptGenerator(
 
128
  is_winning_story=self.is_winning_story,
129
  story_history=game_state.format_history()
130
  )
 
131
 
132
  # Generate image prompts
133
  prompts_response = await self.image_prompt_generator.generate(
 
139
  turn_before_end=self.turn_before_end,
140
  is_winning_story=self.is_winning_story
141
  )
 
142
 
143
  # Create choices
144
  choices = [
 
149
  response = StoryResponse(
150
  story_text=story_text,
151
  choices=choices,
152
+ raw_choices=metadata_response.choices,
153
  time=metadata_response.time,
154
  location=metadata_response.location,
 
155
  image_prompts=prompts_response.image_prompts,
156
  is_first_step=(game_state.story_beat == GameConfig.STORY_BEAT_INTRO),
157
  is_death=metadata_response.is_death,
158
+ is_victory=metadata_response.is_victory,
159
+ previous_choice=previous_choice
160
  )
161
 
162
+ # Add the response to game state history
163
+ game_state.add_to_history(response)
164
+
165
+ return response
166
+
167
  except Exception as e:
168
  print(f"Unexpected error in generate_story_segment: {str(e)}")
169
  raise
server/core/styles/universe_styles.json CHANGED
@@ -43,7 +43,6 @@
43
  "Fantasy",
44
  "Adventure",
45
  "Mystery",
46
- "Romance",
47
  "Horror",
48
  "Drama"
49
  ],
 
43
  "Fantasy",
44
  "Adventure",
45
  "Mystery",
 
46
  "Horror",
47
  "Drama"
48
  ],
server/scripts/test_game.py CHANGED
@@ -41,26 +41,31 @@ def print_universe_info(style: str, genre: str, epoch: str, base_story: str):
41
  print_separator("*")
42
 
43
  def print_story_step(step_number, story_text, image_prompts, generation_time: float, story_history: str = None, show_context: bool = False, model_name: str = None, is_death: bool = False, is_victory: bool = False):
44
- print_separator("=")
45
- print(f"📖 STEP {step_number}")
46
- print(f"⏱️ Generation time: {generation_time:.2f}s (model: {model_name})")
47
- print(f"💀 Death: {is_death}")
48
- print(f"🏆 Victory: {is_victory}")
49
 
50
  if show_context and story_history:
51
- print_separator("-")
52
- print("📚 FULL CONTEXT:")
53
  print(story_history)
 
54
 
55
- print_separator("-")
56
- print("📜 STORY:")
57
  print(story_text)
58
- print_separator("-")
59
- print("🎬 STORYBOARD:")
60
- for i, prompt in enumerate(image_prompts, 1):
61
- print(f"\nPanel {i}:")
62
- print(f" {prompt}")
63
- print_separator("=")
 
 
 
 
 
 
64
 
65
  async def play_game(show_context: bool = False, auto_mode: bool = False, max_turns: int = 15):
66
  # Initialize components
@@ -133,10 +138,10 @@ async def play_game(show_context: bool = False, auto_mode: bool = False, max_tur
133
  story_history = ""
134
  if game_state.story_history:
135
  segments = []
136
- for entry in game_state.story_history:
137
- segment = entry['segment']
138
- time_location = f"[{entry['time']} - {entry['location']}]"
139
- image_descriptions = "\nVisual panels:\n" + "\n".join(f"- {prompt}" for prompt in entry['image_prompts'])
140
  segments.append(f"{time_location}\n{segment}{image_descriptions}")
141
 
142
  story_history = "\n\n---\n\n".join(segments)
@@ -199,13 +204,7 @@ async def play_game(show_context: bool = False, auto_mode: bool = False, max_tur
199
 
200
  # Update game state
201
  game_state.story_beat += 1
202
- game_state.add_to_history(
203
- response.story_text,
204
- f"Choice {last_choice}",
205
- response.image_prompts,
206
- response.time,
207
- response.location
208
- )
209
 
210
  else:
211
  print("\n❌ Error: Invalid number of choices received from server")
 
41
  print_separator("*")
42
 
43
  def print_story_step(step_number, story_text, image_prompts, generation_time: float, story_history: str = None, show_context: bool = False, model_name: str = None, is_death: bool = False, is_victory: bool = False):
44
+ """Print a story step with formatting."""
45
+ print("\n" + "="*80)
46
+ print(f"Step {step_number}")
47
+ print("-"*80)
 
48
 
49
  if show_context and story_history:
50
+ print("\nContext:")
51
+ print("-"*20)
52
  print(story_history)
53
+ print("-"*20 + "\n")
54
 
55
+ print(f"Story ({generation_time:.2f}s):")
 
56
  print(story_text)
57
+
58
+ if image_prompts:
59
+ print("\nImage Prompts:")
60
+ for i, prompt in enumerate(image_prompts, 1):
61
+ print(f"{i}. {prompt}")
62
+
63
+ if is_death:
64
+ print("\n💀 GAME OVER - You died!")
65
+ elif is_victory:
66
+ print("\n🏆 VICTORY - You won!")
67
+
68
+ print("="*80)
69
 
70
  async def play_game(show_context: bool = False, auto_mode: bool = False, max_turns: int = 15):
71
  # Initialize components
 
138
  story_history = ""
139
  if game_state.story_history:
140
  segments = []
141
+ for story_response in game_state.story_history:
142
+ segment = story_response.story_text
143
+ time_location = f"[{story_response.time} - {story_response.location}]"
144
+ image_descriptions = "\nVisual panels:\n" + "\n".join(f"- {prompt}" for prompt in story_response.image_prompts)
145
  segments.append(f"{time_location}\n{segment}{image_descriptions}")
146
 
147
  story_history = "\n\n---\n\n".join(segments)
 
204
 
205
  # Update game state
206
  game_state.story_beat += 1
207
+ # Le StoryResponse est déjà ajouté à l'historique dans generate_story_segment
 
 
 
 
 
 
208
 
209
  else:
210
  print("\n❌ Error: Invalid number of choices received from server")
server/services/flux_client.py CHANGED
@@ -17,7 +17,7 @@ class FluxClient:
17
  prompt: str,
18
  width: int,
19
  height: int,
20
- num_inference_steps: int = 3,
21
  guidance_scale: float = 9.0) -> Optional[bytes]:
22
  """Génère une image à partir d'un prompt."""
23
  try:
 
17
  prompt: str,
18
  width: int,
19
  height: int,
20
+ num_inference_steps: int = 5,
21
  guidance_scale: float = 9.0) -> Optional[bytes]:
22
  """Génère une image à partir d'un prompt."""
23
  try:
server/services/mistral_client.py CHANGED
@@ -30,6 +30,22 @@ logger = logging.getLogger(__name__)
30
  #
31
  # Pricing: https://docs.mistral.ai/platform/pricing/
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  class MistralClient:
34
  def __init__(self, api_key: str, model_name: str = "mistral-large-latest", max_tokens: int = 1000):
35
  logger.info(f"Initializing MistralClient with model: {model_name}, max_tokens: {max_tokens}")
@@ -48,6 +64,8 @@ class MistralClient:
48
  self.last_call_time = 0
49
  self.min_delay = 1 # 1 seconde minimum entre les appels
50
  self.max_retries = 5
 
 
51
 
52
  async def _wait_for_rate_limit(self):
53
  """Attend le temps nécessaire pour respecter le rate limit."""
@@ -61,6 +79,19 @@ class MistralClient:
61
 
62
  self.last_call_time = asyncio.get_event_loop().time()
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  async def _generate_with_retry(
65
  self,
66
  messages: list[BaseMessage],
@@ -68,77 +99,68 @@ class MistralClient:
68
  custom_parser: Optional[Callable[[str], T]] = None,
69
  error_feedback: str = None
70
  ) -> T | str:
71
- """
72
- Génère une réponse avec retry et parsing structuré optionnel.
73
-
74
- Args:
75
- messages: Liste des messages pour le modèle
76
- response_model: Classe Pydantic pour parser la réponse
77
- custom_parser: Fonction de parsing personnalisée
78
- error_feedback: Feedback d'erreur à ajouter au prompt en cas de retry
79
- """
80
  retry_count = 0
81
  last_error = None
82
 
83
  while retry_count < self.max_retries:
84
  try:
85
- # Log attempt
86
  logger.info(f"Attempt {retry_count + 1}/{self.max_retries}")
87
 
88
- # Ajouter le feedback d'erreur si présent
89
  current_messages = messages.copy()
90
  if error_feedback and retry_count > 0:
91
- logger.info(f"Adding error feedback: {error_feedback}")
92
- current_messages.append(HumanMessage(content=f"Previous error: {error_feedback}. Please try again."))
 
 
 
 
93
 
94
- # Log request details
95
- logger.debug("Request details:")
96
- for msg in current_messages:
97
- logger.debug(f"- {msg.type}: {msg.content[:100]}...")
98
-
99
- # Générer la réponse
100
  await self._wait_for_rate_limit()
101
  try:
102
  response = await self.model.ainvoke(current_messages)
103
  content = response.content
104
  logger.debug(f"Raw response: {content[:100]}...")
105
  except Exception as api_error:
106
- logger.error(f"API Error: {str(api_error)}")
107
- if "403" in str(api_error):
108
- logger.error("Received 403 Forbidden - This might indicate an invalid API key or quota exceeded")
 
 
109
  raise
110
-
111
  # Si pas de parsing requis, retourner le contenu brut
112
  if not response_model and not custom_parser:
113
  return content
114
-
115
  # Parser la réponse
116
- if custom_parser:
117
- return custom_parser(content)
118
-
119
- # Essayer de parser avec le modèle Pydantic
120
  try:
 
 
 
 
121
  data = json.loads(content)
122
  return response_model(**data)
123
  except json.JSONDecodeError as e:
124
- last_error = f"Invalid JSON format: {str(e)}"
125
- logger.error(f"JSON parsing error: {last_error}")
126
- raise ValueError(last_error)
127
  except Exception as e:
128
- last_error = str(e)
129
- logger.error(f"Pydantic parsing error: {last_error}")
130
- raise ValueError(last_error)
131
-
132
- except Exception as e:
133
  logger.error(f"Error on attempt {retry_count + 1}/{self.max_retries}: {str(e)}")
 
134
  retry_count += 1
135
  if retry_count < self.max_retries:
136
- wait_time = 2 * retry_count
137
  logger.info(f"Waiting {wait_time} seconds before retry...")
138
  await asyncio.sleep(wait_time)
139
  continue
140
- logger.error(f"Failed after {self.max_retries} attempts. Last error: {last_error or str(e)}")
141
- raise Exception(f"Failed after {self.max_retries} attempts. Last error: {last_error or str(e)}")
 
142
 
143
  async def generate(self, messages: list[BaseMessage], response_model: Optional[Type[T]] = None, custom_parser: Optional[Callable[[str], T]] = None) -> T | str:
144
  """Génère une réponse à partir d'une liste de messages avec parsing optionnel."""
 
30
  #
31
  # Pricing: https://docs.mistral.ai/platform/pricing/
32
 
33
+ class MistralAPIError(Exception):
34
+ """Base class for Mistral API errors"""
35
+ pass
36
+
37
+ class MistralRateLimitError(MistralAPIError):
38
+ """Raised when hitting rate limits"""
39
+ pass
40
+
41
+ class MistralParsingError(MistralAPIError):
42
+ """Raised when response parsing fails"""
43
+ pass
44
+
45
+ class MistralValidationError(MistralAPIError):
46
+ """Raised when response validation fails"""
47
+ pass
48
+
49
  class MistralClient:
50
  def __init__(self, api_key: str, model_name: str = "mistral-large-latest", max_tokens: int = 1000):
51
  logger.info(f"Initializing MistralClient with model: {model_name}, max_tokens: {max_tokens}")
 
64
  self.last_call_time = 0
65
  self.min_delay = 1 # 1 seconde minimum entre les appels
66
  self.max_retries = 5
67
+ self.backoff_factor = 2 # For exponential backoff
68
+ self.max_backoff = 30 # Maximum backoff time in seconds
69
 
70
  async def _wait_for_rate_limit(self):
71
  """Attend le temps nécessaire pour respecter le rate limit."""
 
79
 
80
  self.last_call_time = asyncio.get_event_loop().time()
81
 
82
+ async def _handle_api_error(self, error: Exception, retry_count: int) -> float:
83
+ """Handle API errors and return wait time for retry"""
84
+ wait_time = min(self.backoff_factor ** retry_count, self.max_backoff)
85
+
86
+ if "rate limit" in str(error).lower():
87
+ logger.warning(f"Rate limit hit, waiting {wait_time}s before retry")
88
+ raise MistralRateLimitError(str(error))
89
+ elif "403" in str(error):
90
+ logger.error("Authentication error - invalid API key or quota exceeded")
91
+ raise MistralAPIError("Authentication failed")
92
+
93
+ return wait_time
94
+
95
  async def _generate_with_retry(
96
  self,
97
  messages: list[BaseMessage],
 
99
  custom_parser: Optional[Callable[[str], T]] = None,
100
  error_feedback: str = None
101
  ) -> T | str:
 
 
 
 
 
 
 
 
 
102
  retry_count = 0
103
  last_error = None
104
 
105
  while retry_count < self.max_retries:
106
  try:
 
107
  logger.info(f"Attempt {retry_count + 1}/{self.max_retries}")
108
 
 
109
  current_messages = messages.copy()
110
  if error_feedback and retry_count > 0:
111
+ if isinstance(last_error, MistralParsingError):
112
+ # For parsing errors, add structured format reminder
113
+ current_messages.append(HumanMessage(content="Please ensure your response is in valid JSON format."))
114
+ elif isinstance(last_error, MistralValidationError):
115
+ # For validation errors, add the specific feedback
116
+ current_messages.append(HumanMessage(content=f"Previous error: {error_feedback}. Please try again."))
117
 
 
 
 
 
 
 
118
  await self._wait_for_rate_limit()
119
  try:
120
  response = await self.model.ainvoke(current_messages)
121
  content = response.content
122
  logger.debug(f"Raw response: {content[:100]}...")
123
  except Exception as api_error:
124
+ wait_time = await self._handle_api_error(api_error, retry_count)
125
+ retry_count += 1
126
+ if retry_count < self.max_retries:
127
+ await asyncio.sleep(wait_time)
128
+ continue
129
  raise
130
+
131
  # Si pas de parsing requis, retourner le contenu brut
132
  if not response_model and not custom_parser:
133
  return content
134
+
135
  # Parser la réponse
 
 
 
 
136
  try:
137
+ if custom_parser:
138
+ return custom_parser(content)
139
+
140
+ # Essayer de parser avec le modèle Pydantic
141
  data = json.loads(content)
142
  return response_model(**data)
143
  except json.JSONDecodeError as e:
144
+ last_error = MistralParsingError(f"Invalid JSON format: {str(e)}")
145
+ logger.error(f"JSON parsing error: {str(e)}")
146
+ raise last_error
147
  except Exception as e:
148
+ last_error = MistralValidationError(str(e))
149
+ logger.error(f"Validation error: {str(e)}")
150
+ raise last_error
151
+
152
+ except (MistralParsingError, MistralValidationError) as e:
153
  logger.error(f"Error on attempt {retry_count + 1}/{self.max_retries}: {str(e)}")
154
+ last_error = e
155
  retry_count += 1
156
  if retry_count < self.max_retries:
157
+ wait_time = min(self.backoff_factor ** retry_count, self.max_backoff)
158
  logger.info(f"Waiting {wait_time} seconds before retry...")
159
  await asyncio.sleep(wait_time)
160
  continue
161
+
162
+ logger.error(f"Failed after {self.max_retries} attempts. Last error: {str(last_error)}")
163
+ raise Exception(f"Failed after {self.max_retries} attempts. Last error: {str(last_error)}")
164
 
165
  async def generate(self, messages: list[BaseMessage], response_model: Optional[Type[T]] = None, custom_parser: Optional[Callable[[str], T]] = None) -> T | str:
166
  """Génère une réponse à partir d'une liste de messages avec parsing optionnel."""