update
Browse files- .DS_Store +0 -0
- client/public/sounds/transitional-swipe-1.mp3 +0 -0
- client/public/sounds/transitional-swipe-2.mp3 +0 -0
- client/public/sounds/transitional-swipe-3.mp3 +0 -0
- client/src/components/StoryChoices.jsx +0 -1
- client/src/components/TalkWithSarah.jsx +279 -0
- client/src/hooks/useTransitionSound.js +45 -0
- client/src/layouts/Panel.jsx +1 -14
- client/src/pages/Game.jsx +31 -14
- client/src/utils/api.js +5 -12
- server/api/models.py +1 -9
- server/api/routes/chat.py +3 -10
- server/core/constants.py +1 -8
- server/core/game_logic.py +6 -26
- server/core/generators/metadata_generator.py +6 -4
- server/core/generators/text_generator.py +1 -5
- server/core/prompts/cinematic.py +2 -2
- server/core/prompts/convice.py +0 -28
- server/core/prompts/image_style.py +2 -2
- server/core/prompts/system.py +4 -3
- server/core/prompts/text_prompts.py +4 -3
- server/core/state/game_state.py +1 -9
- server/core/story_orchestrator.py +0 -4
- server/scripts/test_game.py +4 -11
.DS_Store
CHANGED
Binary files a/.DS_Store and b/.DS_Store differ
|
|
client/public/sounds/transitional-swipe-1.mp3
ADDED
Binary file (120 kB). View file
|
|
client/public/sounds/transitional-swipe-2.mp3
ADDED
Binary file (65.2 kB). View file
|
|
client/public/sounds/transitional-swipe-3.mp3
ADDED
Binary file (81.1 kB). View file
|
|
client/src/components/StoryChoices.jsx
CHANGED
@@ -35,7 +35,6 @@ export function StoryChoices({
|
|
35 |
}) {
|
36 |
const navigate = useNavigate();
|
37 |
|
38 |
-
console.log("ICI", isLastStep, isGameOver);
|
39 |
if (isGameOver) {
|
40 |
return (
|
41 |
<Box
|
|
|
35 |
}) {
|
36 |
const navigate = useNavigate();
|
37 |
|
|
|
38 |
if (isGameOver) {
|
39 |
return (
|
40 |
<Box
|
client/src/components/TalkWithSarah.jsx
ADDED
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useRef, useEffect } from "react";
|
2 |
+
import {
|
3 |
+
Box,
|
4 |
+
IconButton,
|
5 |
+
TextField,
|
6 |
+
Dialog,
|
7 |
+
DialogTitle,
|
8 |
+
DialogContent,
|
9 |
+
DialogActions,
|
10 |
+
Button,
|
11 |
+
Tooltip,
|
12 |
+
} from "@mui/material";
|
13 |
+
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
14 |
+
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
15 |
+
import CancelIcon from "@mui/icons-material/Cancel";
|
16 |
+
import { useConversation } from "@11labs/react";
|
17 |
+
|
18 |
+
const AGENT_ID = "2MF9st3s1mNFbX01Y106";
|
19 |
+
const ELEVEN_LABS_KEY_STORAGE = "eleven_labs_api_key";
|
20 |
+
|
21 |
+
export function TalkWithSarah({
|
22 |
+
isNarratorSpeaking,
|
23 |
+
stopNarration,
|
24 |
+
playNarration,
|
25 |
+
onDecisionMade,
|
26 |
+
currentContext,
|
27 |
+
}) {
|
28 |
+
const [isRecording, setIsRecording] = useState(false);
|
29 |
+
const [isConversationMode, setIsConversationMode] = useState(false);
|
30 |
+
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
|
31 |
+
const [apiKey, setApiKey] = useState(() => {
|
32 |
+
return localStorage.getItem(ELEVEN_LABS_KEY_STORAGE) || "";
|
33 |
+
});
|
34 |
+
const [isApiKeyValid, setIsApiKeyValid] = useState(false);
|
35 |
+
const mediaRecorderRef = useRef(null);
|
36 |
+
const audioChunksRef = useRef([]);
|
37 |
+
const wsRef = useRef(null);
|
38 |
+
|
39 |
+
const conversation = useConversation({
|
40 |
+
agentId: AGENT_ID,
|
41 |
+
headers: {
|
42 |
+
"xi-api-key": apiKey,
|
43 |
+
},
|
44 |
+
onResponse: async (response) => {
|
45 |
+
if (response.type === "audio") {
|
46 |
+
try {
|
47 |
+
const audioBlob = new Blob([response.audio], { type: "audio/mpeg" });
|
48 |
+
const audioUrl = URL.createObjectURL(audioBlob);
|
49 |
+
await playNarration(audioUrl);
|
50 |
+
URL.revokeObjectURL(audioUrl);
|
51 |
+
} catch (error) {
|
52 |
+
console.error("Error playing ElevenLabs audio:", error);
|
53 |
+
}
|
54 |
+
}
|
55 |
+
},
|
56 |
+
clientTools: {
|
57 |
+
make_decision: async ({ decision }) => {
|
58 |
+
console.log("AI made decision:", decision);
|
59 |
+
// Stop recording
|
60 |
+
if (
|
61 |
+
mediaRecorderRef.current &&
|
62 |
+
mediaRecorderRef.current.state === "recording"
|
63 |
+
) {
|
64 |
+
mediaRecorderRef.current.stop();
|
65 |
+
}
|
66 |
+
setIsConversationMode(false);
|
67 |
+
await conversation?.endSession();
|
68 |
+
setIsRecording(false);
|
69 |
+
await onDecisionMade(parseInt(decision));
|
70 |
+
},
|
71 |
+
},
|
72 |
+
});
|
73 |
+
|
74 |
+
// Valider la clé API
|
75 |
+
const validateApiKey = async (key) => {
|
76 |
+
try {
|
77 |
+
const response = await fetch("https://api.elevenlabs.io/v1/user", {
|
78 |
+
headers: {
|
79 |
+
"xi-api-key": key,
|
80 |
+
},
|
81 |
+
});
|
82 |
+
return response.ok;
|
83 |
+
} catch (error) {
|
84 |
+
return false;
|
85 |
+
}
|
86 |
+
};
|
87 |
+
|
88 |
+
// Vérifier la validité de la clé API quand elle change
|
89 |
+
useEffect(() => {
|
90 |
+
const checkApiKey = async () => {
|
91 |
+
if (apiKey) {
|
92 |
+
const isValid = await validateApiKey(apiKey);
|
93 |
+
setIsApiKeyValid(isValid);
|
94 |
+
if (isValid) {
|
95 |
+
localStorage.setItem(ELEVEN_LABS_KEY_STORAGE, apiKey);
|
96 |
+
}
|
97 |
+
} else {
|
98 |
+
setIsApiKeyValid(false);
|
99 |
+
}
|
100 |
+
};
|
101 |
+
checkApiKey();
|
102 |
+
}, [apiKey]);
|
103 |
+
|
104 |
+
// Sauvegarder la clé API dans le localStorage
|
105 |
+
useEffect(() => {
|
106 |
+
if (apiKey) {
|
107 |
+
localStorage.setItem(ELEVEN_LABS_KEY_STORAGE, apiKey);
|
108 |
+
}
|
109 |
+
}, [apiKey]);
|
110 |
+
|
111 |
+
const startRecording = async () => {
|
112 |
+
if (!apiKey) {
|
113 |
+
setShowApiKeyDialog(true);
|
114 |
+
return;
|
115 |
+
}
|
116 |
+
|
117 |
+
try {
|
118 |
+
setIsRecording(true);
|
119 |
+
// Stop narration audio if it's playing
|
120 |
+
if (isNarratorSpeaking) {
|
121 |
+
stopNarration();
|
122 |
+
}
|
123 |
+
|
124 |
+
// Safely stop any conversation audio if playing
|
125 |
+
if (conversation?.audioRef?.current) {
|
126 |
+
conversation.audioRef.current.pause();
|
127 |
+
conversation.audioRef.current.currentTime = 0;
|
128 |
+
}
|
129 |
+
|
130 |
+
if (!isConversationMode) {
|
131 |
+
setIsConversationMode(true);
|
132 |
+
try {
|
133 |
+
if (!conversation) {
|
134 |
+
throw new Error("Conversation not initialized");
|
135 |
+
}
|
136 |
+
await conversation.startSession({
|
137 |
+
agentId: AGENT_ID,
|
138 |
+
initialContext: currentContext,
|
139 |
+
});
|
140 |
+
console.log("ElevenLabs WebSocket connected");
|
141 |
+
} catch (error) {
|
142 |
+
console.error("Error starting conversation:", error);
|
143 |
+
return;
|
144 |
+
}
|
145 |
+
}
|
146 |
+
|
147 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
148 |
+
mediaRecorderRef.current = new MediaRecorder(stream);
|
149 |
+
audioChunksRef.current = [];
|
150 |
+
|
151 |
+
mediaRecorderRef.current.ondataavailable = (event) => {
|
152 |
+
if (event.data.size > 0) {
|
153 |
+
audioChunksRef.current.push(event.data);
|
154 |
+
}
|
155 |
+
};
|
156 |
+
|
157 |
+
mediaRecorderRef.current.onstop = async () => {
|
158 |
+
const audioBlob = new Blob(audioChunksRef.current, {
|
159 |
+
type: "audio/wav",
|
160 |
+
});
|
161 |
+
audioChunksRef.current = [];
|
162 |
+
|
163 |
+
const reader = new FileReader();
|
164 |
+
reader.readAsDataURL(audioBlob);
|
165 |
+
|
166 |
+
reader.onload = async () => {
|
167 |
+
const base64Audio = reader.result.split(",")[1];
|
168 |
+
if (isConversationMode) {
|
169 |
+
try {
|
170 |
+
// Send audio to ElevenLabs conversation
|
171 |
+
await conversation.send({
|
172 |
+
type: "audio",
|
173 |
+
data: base64Audio,
|
174 |
+
});
|
175 |
+
} catch (error) {
|
176 |
+
console.error("Error sending audio to ElevenLabs:", error);
|
177 |
+
}
|
178 |
+
}
|
179 |
+
};
|
180 |
+
};
|
181 |
+
|
182 |
+
mediaRecorderRef.current.start();
|
183 |
+
} catch (error) {
|
184 |
+
console.error("Error starting recording:", error);
|
185 |
+
}
|
186 |
+
};
|
187 |
+
|
188 |
+
const handleSaveApiKey = () => {
|
189 |
+
setShowApiKeyDialog(false);
|
190 |
+
if (apiKey) {
|
191 |
+
startRecording();
|
192 |
+
}
|
193 |
+
};
|
194 |
+
|
195 |
+
return (
|
196 |
+
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
197 |
+
<Box sx={{ position: "relative", display: "flex", alignItems: "center" }}>
|
198 |
+
<TextField
|
199 |
+
size="small"
|
200 |
+
type="password"
|
201 |
+
placeholder="Enter your ElevenLabs API key"
|
202 |
+
value={apiKey}
|
203 |
+
onChange={(e) => setApiKey(e.target.value)}
|
204 |
+
sx={{
|
205 |
+
width: "300px",
|
206 |
+
"& .MuiOutlinedInput-root": {
|
207 |
+
color: "white",
|
208 |
+
"& fieldset": {
|
209 |
+
borderColor: "rgba(255, 255, 255, 0.23)",
|
210 |
+
},
|
211 |
+
"&:hover fieldset": {
|
212 |
+
borderColor: "white",
|
213 |
+
},
|
214 |
+
"&.Mui-focused fieldset": {
|
215 |
+
borderColor: "white",
|
216 |
+
},
|
217 |
+
"& .MuiOutlinedInput-input": {
|
218 |
+
paddingRight: apiKey ? "40px" : "14px", // Padding dynamique
|
219 |
+
},
|
220 |
+
},
|
221 |
+
"& .MuiInputBase-input": {
|
222 |
+
color: "white",
|
223 |
+
"&::placeholder": {
|
224 |
+
color: "rgba(255, 255, 255, 0.5)",
|
225 |
+
opacity: 1,
|
226 |
+
},
|
227 |
+
},
|
228 |
+
}}
|
229 |
+
/>
|
230 |
+
{apiKey && (
|
231 |
+
<Tooltip
|
232 |
+
title={isApiKeyValid ? "API key is valid" : "Invalid API key"}
|
233 |
+
>
|
234 |
+
<Box
|
235 |
+
sx={{
|
236 |
+
position: "absolute",
|
237 |
+
right: 10,
|
238 |
+
pointerEvents: "none",
|
239 |
+
display: "flex",
|
240 |
+
alignItems: "center",
|
241 |
+
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
242 |
+
borderRadius: "50%",
|
243 |
+
padding: "2px",
|
244 |
+
}}
|
245 |
+
>
|
246 |
+
{isApiKeyValid ? (
|
247 |
+
<CheckCircleIcon sx={{ color: "#4caf50", fontSize: 20 }} />
|
248 |
+
) : (
|
249 |
+
<CancelIcon sx={{ color: "#f44336", fontSize: 20 }} />
|
250 |
+
)}
|
251 |
+
</Box>
|
252 |
+
</Tooltip>
|
253 |
+
)}
|
254 |
+
</Box>
|
255 |
+
<IconButton
|
256 |
+
onClick={startRecording}
|
257 |
+
disabled={isRecording || !isApiKeyValid}
|
258 |
+
sx={{
|
259 |
+
color: "white",
|
260 |
+
backgroundColor: isRecording ? "primary.main" : "transparent",
|
261 |
+
"&:hover": {
|
262 |
+
backgroundColor: isRecording
|
263 |
+
? "primary.dark"
|
264 |
+
: "rgba(0, 0, 0, 0.7)",
|
265 |
+
},
|
266 |
+
px: 2,
|
267 |
+
borderRadius: 2,
|
268 |
+
border: "1px solid white",
|
269 |
+
opacity: !isApiKeyValid ? 0.5 : 1,
|
270 |
+
}}
|
271 |
+
>
|
272 |
+
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
273 |
+
{isRecording ? <FiberManualRecordIcon sx={{ color: "red" }} /> : null}
|
274 |
+
<span style={{ fontSize: "1rem" }}>Talk with Sarah</span>
|
275 |
+
</Box>
|
276 |
+
</IconButton>
|
277 |
+
</Box>
|
278 |
+
);
|
279 |
+
}
|
client/src/hooks/useTransitionSound.js
ADDED
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useSound } from "use-sound";
|
2 |
+
import { useState, useEffect } from "react";
|
3 |
+
|
4 |
+
const TRANSITION_SOUNDS = Array.from(
|
5 |
+
{ length: 3 },
|
6 |
+
(_, i) => `/sounds/transitional-swipe-${i + 1}.mp3`
|
7 |
+
);
|
8 |
+
|
9 |
+
export function useTransitionSound(isSoundEnabled = true) {
|
10 |
+
const [soundsLoaded, setSoundsLoaded] = useState(false);
|
11 |
+
|
12 |
+
// Créer un tableau de hooks useSound pour chaque son
|
13 |
+
const sounds = TRANSITION_SOUNDS.map((soundPath) => {
|
14 |
+
const [play, { sound }] = useSound(soundPath, {
|
15 |
+
volume: 0.1,
|
16 |
+
interrupt: true,
|
17 |
+
});
|
18 |
+
return { play, sound };
|
19 |
+
});
|
20 |
+
|
21 |
+
// Vérifier quand tous les sons sont chargés
|
22 |
+
useEffect(() => {
|
23 |
+
const allSoundsLoaded = sounds.every(
|
24 |
+
({ sound }) => sound && sound.state() === "loaded"
|
25 |
+
);
|
26 |
+
if (allSoundsLoaded) {
|
27 |
+
setSoundsLoaded(true);
|
28 |
+
}
|
29 |
+
}, [sounds]);
|
30 |
+
|
31 |
+
const playRandomTransitionSound = () => {
|
32 |
+
if (!isSoundEnabled || !soundsLoaded) {
|
33 |
+
return;
|
34 |
+
}
|
35 |
+
|
36 |
+
const randomIndex = Math.floor(Math.random() * sounds.length);
|
37 |
+
try {
|
38 |
+
sounds[randomIndex].play();
|
39 |
+
} catch (error) {
|
40 |
+
console.error("Error playing transition sound:", error);
|
41 |
+
}
|
42 |
+
};
|
43 |
+
|
44 |
+
return playRandomTransitionSound;
|
45 |
+
}
|
client/src/layouts/Panel.jsx
CHANGED
@@ -9,11 +9,6 @@ export function Panel({ segment, panel, panelIndex }) {
|
|
9 |
// Reset states when the image changes
|
10 |
useEffect(() => {
|
11 |
const hasImage = !!segment?.images?.[panelIndex];
|
12 |
-
console.log(`[Panel ${panelIndex}] Image changed:`, {
|
13 |
-
hasSegment: !!segment,
|
14 |
-
hasImage,
|
15 |
-
imageContent: segment?.images?.[panelIndex]?.slice(0, 50),
|
16 |
-
});
|
17 |
|
18 |
// Ne réinitialiser les états que si on n'a pas d'image
|
19 |
if (!hasImage) {
|
@@ -23,17 +18,9 @@ export function Panel({ segment, panel, panelIndex }) {
|
|
23 |
}, [segment?.images?.[panelIndex]]);
|
24 |
|
25 |
// Log component state changes
|
26 |
-
useEffect(() => {
|
27 |
-
console.log(`[Panel ${panelIndex}] State updated:`, {
|
28 |
-
imageLoaded,
|
29 |
-
isLoading,
|
30 |
-
hasSegment: !!segment,
|
31 |
-
hasImage: !!segment?.images?.[panelIndex],
|
32 |
-
});
|
33 |
-
}, [imageLoaded, isLoading, segment, panelIndex]);
|
34 |
|
35 |
const handleImageLoad = () => {
|
36 |
-
console.log(`[Panel ${panelIndex}] Image loaded successfully`);
|
37 |
setImageLoaded(true);
|
38 |
setIsLoading(false);
|
39 |
};
|
|
|
9 |
// Reset states when the image changes
|
10 |
useEffect(() => {
|
11 |
const hasImage = !!segment?.images?.[panelIndex];
|
|
|
|
|
|
|
|
|
|
|
12 |
|
13 |
// Ne réinitialiser les états que si on n'a pas d'image
|
14 |
if (!hasImage) {
|
|
|
18 |
}, [segment?.images?.[panelIndex]]);
|
19 |
|
20 |
// Log component state changes
|
21 |
+
useEffect(() => {}, [imageLoaded, isLoading, segment, panelIndex]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
const handleImageLoad = () => {
|
|
|
24 |
setImageLoaded(true);
|
25 |
setIsLoading(false);
|
26 |
};
|
client/src/pages/Game.jsx
CHANGED
@@ -14,6 +14,7 @@ import { useNarrator } from "../hooks/useNarrator";
|
|
14 |
import { useStoryCapture } from "../hooks/useStoryCapture";
|
15 |
import { usePageSound } from "../hooks/usePageSound";
|
16 |
import { useWritingSound } from "../hooks/useWritingSound";
|
|
|
17 |
import { useGameSession } from "../hooks/useGameSession";
|
18 |
import { StoryChoices } from "../components/StoryChoices";
|
19 |
import { ErrorDisplay } from "../components/ErrorDisplay";
|
@@ -25,6 +26,8 @@ import CreateIcon from "@mui/icons-material/Create";
|
|
25 |
import { getNextLayoutType, LAYOUTS } from "../layouts/config";
|
26 |
import { LoadingScreen } from "../components/LoadingScreen";
|
27 |
|
|
|
|
|
28 |
// Constants
|
29 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
30 |
|
@@ -69,6 +72,7 @@ export function Game() {
|
|
69 |
useNarrator(isSoundEnabled);
|
70 |
const playPageSound = usePageSound(isSoundEnabled);
|
71 |
const playWritingSound = useWritingSound(isSoundEnabled);
|
|
|
72 |
const {
|
73 |
sessionId,
|
74 |
universe,
|
@@ -76,6 +80,15 @@ export function Game() {
|
|
76 |
error: sessionError,
|
77 |
} = useGameSession();
|
78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
79 |
// Sauvegarder l'état du son dans le localStorage
|
80 |
useEffect(() => {
|
81 |
localStorage.setItem(SOUND_ENABLED_KEY, isSoundEnabled);
|
@@ -144,7 +157,6 @@ export function Game() {
|
|
144 |
stopNarration();
|
145 |
}
|
146 |
|
147 |
-
console.log("Starting story action:", action);
|
148 |
// Pass sessionId to API calls
|
149 |
const storyData = await (action === "restart"
|
150 |
? storyApi.start(sessionId)
|
@@ -161,7 +173,6 @@ export function Game() {
|
|
161 |
isChoice: false,
|
162 |
isDeath: storyData.is_death,
|
163 |
isVictory: storyData.is_victory,
|
164 |
-
radiationLevel: storyData.radiation_level,
|
165 |
is_first_step: storyData.is_first_step,
|
166 |
is_last_step: storyData.is_last_step,
|
167 |
images: [],
|
@@ -185,10 +196,6 @@ export function Game() {
|
|
185 |
|
186 |
// 6. Generate images in parallel
|
187 |
if (storyData.image_prompts && storyData.image_prompts.length > 0) {
|
188 |
-
console.log(
|
189 |
-
"Starting image generation for prompts:",
|
190 |
-
storyData.image_prompts
|
191 |
-
);
|
192 |
await generateImagesForStory(
|
193 |
storyData.image_prompts,
|
194 |
action === "restart" ? 0 : storySegments.length,
|
@@ -232,9 +239,6 @@ export function Game() {
|
|
232 |
|
233 |
// Déterminer le layout en fonction du nombre d'images
|
234 |
const layoutType = getNextLayoutType(0, imagePrompts.length);
|
235 |
-
console.log(
|
236 |
-
`Using layout ${layoutType} for ${imagePrompts.length} images`
|
237 |
-
);
|
238 |
|
239 |
for (
|
240 |
let promptIndex = 0;
|
@@ -247,13 +251,9 @@ export function Game() {
|
|
247 |
|
248 |
// Obtenir les dimensions pour ce panneau
|
249 |
const panelDimensions = LAYOUTS[layoutType].panels[promptIndex];
|
250 |
-
console.log(`Panel ${promptIndex} dimensions:`, panelDimensions);
|
251 |
|
252 |
while (retryCount < maxRetries && !success) {
|
253 |
try {
|
254 |
-
console.log(
|
255 |
-
`Generating image ${promptIndex + 1}/${imagePrompts.length}`
|
256 |
-
);
|
257 |
const result = await storyApi.generateImage(
|
258 |
imagePrompts[promptIndex],
|
259 |
panelDimensions.width,
|
@@ -265,7 +265,6 @@ export function Game() {
|
|
265 |
}
|
266 |
|
267 |
if (result.success) {
|
268 |
-
console.log(`Successfully generated image ${promptIndex + 1}`);
|
269 |
images[promptIndex] = result.image_base64;
|
270 |
|
271 |
// Vérifier si toutes les images sont générées
|
@@ -432,6 +431,24 @@ export function Game() {
|
|
432 |
showScreenshot={storySegments.length > 0}
|
433 |
onScreenshot={handleCaptureStory}
|
434 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
435 |
{showChoices && (
|
436 |
<StoryChoices
|
437 |
choices={currentChoices}
|
|
|
14 |
import { useStoryCapture } from "../hooks/useStoryCapture";
|
15 |
import { usePageSound } from "../hooks/usePageSound";
|
16 |
import { useWritingSound } from "../hooks/useWritingSound";
|
17 |
+
import { useTransitionSound } from "../hooks/useTransitionSound";
|
18 |
import { useGameSession } from "../hooks/useGameSession";
|
19 |
import { StoryChoices } from "../components/StoryChoices";
|
20 |
import { ErrorDisplay } from "../components/ErrorDisplay";
|
|
|
26 |
import { getNextLayoutType, LAYOUTS } from "../layouts/config";
|
27 |
import { LoadingScreen } from "../components/LoadingScreen";
|
28 |
|
29 |
+
import { TalkWithSarah } from "../components/TalkWithSarah";
|
30 |
+
|
31 |
// Constants
|
32 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
33 |
|
|
|
72 |
useNarrator(isSoundEnabled);
|
73 |
const playPageSound = usePageSound(isSoundEnabled);
|
74 |
const playWritingSound = useWritingSound(isSoundEnabled);
|
75 |
+
const playTransitionSound = useTransitionSound(isSoundEnabled);
|
76 |
const {
|
77 |
sessionId,
|
78 |
universe,
|
|
|
80 |
error: sessionError,
|
81 |
} = useGameSession();
|
82 |
|
83 |
+
// Jouer le son de transition une fois que la session est chargée
|
84 |
+
useEffect(() => {
|
85 |
+
if (!isSessionLoading && sessionId && !error && !sessionError) {
|
86 |
+
setTimeout(() => {
|
87 |
+
playTransitionSound();
|
88 |
+
}, 100);
|
89 |
+
}
|
90 |
+
}, [isSessionLoading, sessionId, error, sessionError]);
|
91 |
+
|
92 |
// Sauvegarder l'état du son dans le localStorage
|
93 |
useEffect(() => {
|
94 |
localStorage.setItem(SOUND_ENABLED_KEY, isSoundEnabled);
|
|
|
157 |
stopNarration();
|
158 |
}
|
159 |
|
|
|
160 |
// Pass sessionId to API calls
|
161 |
const storyData = await (action === "restart"
|
162 |
? storyApi.start(sessionId)
|
|
|
173 |
isChoice: false,
|
174 |
isDeath: storyData.is_death,
|
175 |
isVictory: storyData.is_victory,
|
|
|
176 |
is_first_step: storyData.is_first_step,
|
177 |
is_last_step: storyData.is_last_step,
|
178 |
images: [],
|
|
|
196 |
|
197 |
// 6. Generate images in parallel
|
198 |
if (storyData.image_prompts && storyData.image_prompts.length > 0) {
|
|
|
|
|
|
|
|
|
199 |
await generateImagesForStory(
|
200 |
storyData.image_prompts,
|
201 |
action === "restart" ? 0 : storySegments.length,
|
|
|
239 |
|
240 |
// Déterminer le layout en fonction du nombre d'images
|
241 |
const layoutType = getNextLayoutType(0, imagePrompts.length);
|
|
|
|
|
|
|
242 |
|
243 |
for (
|
244 |
let promptIndex = 0;
|
|
|
251 |
|
252 |
// Obtenir les dimensions pour ce panneau
|
253 |
const panelDimensions = LAYOUTS[layoutType].panels[promptIndex];
|
|
|
254 |
|
255 |
while (retryCount < maxRetries && !success) {
|
256 |
try {
|
|
|
|
|
|
|
257 |
const result = await storyApi.generateImage(
|
258 |
imagePrompts[promptIndex],
|
259 |
panelDimensions.width,
|
|
|
265 |
}
|
266 |
|
267 |
if (result.success) {
|
|
|
268 |
images[promptIndex] = result.image_base64;
|
269 |
|
270 |
// Vérifier si toutes les images sont générées
|
|
|
431 |
showScreenshot={storySegments.length > 0}
|
432 |
onScreenshot={handleCaptureStory}
|
433 |
/>
|
434 |
+
|
435 |
+
{storySegments.length > 0 && currentChoices.length > 0 && (
|
436 |
+
<TalkWithSarah
|
437 |
+
isNarratorSpeaking={isNarratorSpeaking}
|
438 |
+
stopNarration={stopNarration}
|
439 |
+
playNarration={playNarration}
|
440 |
+
onDecisionMade={handleChoice}
|
441 |
+
currentContext={`Sarah this is the situation you're in : ${
|
442 |
+
storySegments[storySegments.length - 1].text
|
443 |
+
}. Those are your possible decisions : \n ${currentChoices
|
444 |
+
.map(
|
445 |
+
(choice, index) =>
|
446 |
+
`decision ${index + 1} : ${choice.text}`
|
447 |
+
)
|
448 |
+
.join("\n ")}.`}
|
449 |
+
/>
|
450 |
+
)}
|
451 |
+
|
452 |
{showChoices && (
|
453 |
<StoryChoices
|
454 |
choices={currentChoices}
|
client/src/utils/api.js
CHANGED
@@ -18,7 +18,11 @@ const api = axios.create({
|
|
18 |
// Add request interceptor to handle headers
|
19 |
api.interceptors.request.use((config) => {
|
20 |
// Routes qui ne nécessitent pas de session_id
|
21 |
-
const noSessionRoutes = [
|
|
|
|
|
|
|
|
|
22 |
|
23 |
if (noSessionRoutes.includes(config.url)) {
|
24 |
return config;
|
@@ -66,7 +70,6 @@ const handleApiError = (error) => {
|
|
66 |
export const storyApi = {
|
67 |
start: async (sessionId) => {
|
68 |
try {
|
69 |
-
console.log("Calling start API with session:", sessionId);
|
70 |
const response = await api.post(
|
71 |
"/api/chat",
|
72 |
{
|
@@ -76,7 +79,6 @@ export const storyApi = {
|
|
76 |
headers: getDefaultHeaders(sessionId),
|
77 |
}
|
78 |
);
|
79 |
-
console.log("Start API response:", response.data);
|
80 |
return response.data;
|
81 |
} catch (error) {
|
82 |
return handleApiError(error);
|
@@ -85,7 +87,6 @@ export const storyApi = {
|
|
85 |
|
86 |
makeChoice: async (choiceId, sessionId) => {
|
87 |
try {
|
88 |
-
console.log("Making choice:", choiceId, "for session:", sessionId);
|
89 |
const response = await api.post(
|
90 |
"/api/chat",
|
91 |
{
|
@@ -96,7 +97,6 @@ export const storyApi = {
|
|
96 |
headers: getDefaultHeaders(sessionId),
|
97 |
}
|
98 |
);
|
99 |
-
console.log("Choice API response:", response.data);
|
100 |
return response.data;
|
101 |
} catch (error) {
|
102 |
return handleApiError(error);
|
@@ -110,7 +110,6 @@ export const storyApi = {
|
|
110 |
sessionId = null
|
111 |
) => {
|
112 |
try {
|
113 |
-
console.log("Generating image with prompt:", prompt);
|
114 |
const config = {
|
115 |
prompt,
|
116 |
width,
|
@@ -123,10 +122,6 @@ export const storyApi = {
|
|
123 |
}
|
124 |
|
125 |
const response = await api.post("/api/generate-image", config, options);
|
126 |
-
console.log("Image generation response:", {
|
127 |
-
success: response.data.success,
|
128 |
-
hasImage: !!response.data.image_base64,
|
129 |
-
});
|
130 |
return response.data;
|
131 |
} catch (error) {
|
132 |
return handleApiError(error);
|
@@ -136,7 +131,6 @@ export const storyApi = {
|
|
136 |
// Narration related API calls
|
137 |
narrate: async (text, sessionId) => {
|
138 |
try {
|
139 |
-
console.log("Requesting narration for:", text);
|
140 |
const response = await api.post(
|
141 |
"/api/text-to-speech",
|
142 |
{
|
@@ -146,7 +140,6 @@ export const storyApi = {
|
|
146 |
headers: getDefaultHeaders(sessionId),
|
147 |
}
|
148 |
);
|
149 |
-
console.log("Narration response received");
|
150 |
return response.data;
|
151 |
} catch (error) {
|
152 |
return handleApiError(error);
|
|
|
18 |
// Add request interceptor to handle headers
|
19 |
api.interceptors.request.use((config) => {
|
20 |
// Routes qui ne nécessitent pas de session_id
|
21 |
+
const noSessionRoutes = [
|
22 |
+
"/api/universe/generate",
|
23 |
+
"/api/generate-image",
|
24 |
+
"/api/text-to-speech",
|
25 |
+
];
|
26 |
|
27 |
if (noSessionRoutes.includes(config.url)) {
|
28 |
return config;
|
|
|
70 |
export const storyApi = {
|
71 |
start: async (sessionId) => {
|
72 |
try {
|
|
|
73 |
const response = await api.post(
|
74 |
"/api/chat",
|
75 |
{
|
|
|
79 |
headers: getDefaultHeaders(sessionId),
|
80 |
}
|
81 |
);
|
|
|
82 |
return response.data;
|
83 |
} catch (error) {
|
84 |
return handleApiError(error);
|
|
|
87 |
|
88 |
makeChoice: async (choiceId, sessionId) => {
|
89 |
try {
|
|
|
90 |
const response = await api.post(
|
91 |
"/api/chat",
|
92 |
{
|
|
|
97 |
headers: getDefaultHeaders(sessionId),
|
98 |
}
|
99 |
);
|
|
|
100 |
return response.data;
|
101 |
} catch (error) {
|
102 |
return handleApiError(error);
|
|
|
110 |
sessionId = null
|
111 |
) => {
|
112 |
try {
|
|
|
113 |
const config = {
|
114 |
prompt,
|
115 |
width,
|
|
|
122 |
}
|
123 |
|
124 |
const response = await api.post("/api/generate-image", config, options);
|
|
|
|
|
|
|
|
|
125 |
return response.data;
|
126 |
} catch (error) {
|
127 |
return handleApiError(error);
|
|
|
131 |
// Narration related API calls
|
132 |
narrate: async (text, sessionId) => {
|
133 |
try {
|
|
|
134 |
const response = await api.post(
|
135 |
"/api/text-to-speech",
|
136 |
{
|
|
|
140 |
headers: getDefaultHeaders(sessionId),
|
141 |
}
|
142 |
);
|
|
|
143 |
return response.data;
|
144 |
} catch (error) {
|
145 |
return handleApiError(error);
|
server/api/models.py
CHANGED
@@ -8,7 +8,7 @@ class Choice(BaseModel):
|
|
8 |
|
9 |
class StorySegmentBase(BaseModel):
|
10 |
"""Base model for story segments with common validation logic"""
|
11 |
-
story_text: str = Field(description="The story text. No more than 15 words THIS IS MANDATORY. Never mention story beat
|
12 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
13 |
is_death: bool = Field(description="Whether this segment ends in Sarah's death", default=False)
|
14 |
|
@@ -24,12 +24,6 @@ class StoryPromptsResponse(BaseModel):
|
|
24 |
)
|
25 |
|
26 |
class StoryMetadataResponse(BaseModel):
|
27 |
-
radiation_increase: int = Field(
|
28 |
-
description=f"How much radiation this segment adds (0-3)",
|
29 |
-
ge=0,
|
30 |
-
le=3,
|
31 |
-
default=GameConfig.DEFAULT_RADIATION_INCREASE
|
32 |
-
)
|
33 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
34 |
is_death: bool = Field(description="Whether this segment ends in Sarah's death", default=False)
|
35 |
choices: List[str] = Field(description="Either empty list for victory/death, or exactly two choices for normal progression")
|
@@ -51,8 +45,6 @@ class StoryMetadataResponse(BaseModel):
|
|
51 |
class StoryResponse(StorySegmentBase):
|
52 |
choices: List[Choice]
|
53 |
raw_choices: List[str] = Field(description="Raw choice texts from LLM before conversion to Choice objects")
|
54 |
-
radiation_level: int = Field(description=f"Current radiation level")
|
55 |
-
radiation_increase: int = Field(description="How much radiation this segment adds (0-3)", ge=0, le=3, default=GameConfig.DEFAULT_RADIATION_INCREASE)
|
56 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.")
|
57 |
location: str = Field(description="Current location.")
|
58 |
is_first_step: bool = Field(description="Whether this is the first step of the story", default=False)
|
|
|
8 |
|
9 |
class StorySegmentBase(BaseModel):
|
10 |
"""Base model for story segments with common validation logic"""
|
11 |
+
story_text: str = Field(description="The story text. No more than 15 words THIS IS MANDATORY. Never mention story beat directly. ")
|
12 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
13 |
is_death: bool = Field(description="Whether this segment ends in Sarah's death", default=False)
|
14 |
|
|
|
24 |
)
|
25 |
|
26 |
class StoryMetadataResponse(BaseModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
28 |
is_death: bool = Field(description="Whether this segment ends in Sarah's death", default=False)
|
29 |
choices: List[str] = Field(description="Either empty list for victory/death, or exactly two choices for normal progression")
|
|
|
45 |
class StoryResponse(StorySegmentBase):
|
46 |
choices: List[Choice]
|
47 |
raw_choices: List[str] = Field(description="Raw choice texts from LLM before conversion to Choice objects")
|
|
|
|
|
48 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.")
|
49 |
location: str = Field(description="Current location.")
|
50 |
is_first_step: bool = Field(description="Whether this is the first step of the story", default=False)
|
server/api/routes/chat.py
CHANGED
@@ -63,12 +63,7 @@ def get_chat_router(session_manager: SessionManager, story_generator):
|
|
63 |
previous_choice=previous_choice
|
64 |
)
|
65 |
|
66 |
-
|
67 |
-
game_state.radiation_level += llm_response.radiation_increase
|
68 |
-
|
69 |
-
# Check for radiation death
|
70 |
-
is_death = game_state.radiation_level >= GameConfig.MAX_RADIATION
|
71 |
-
if is_death:
|
72 |
llm_response.choices = []
|
73 |
llm_response.story_text += "\You have succumbed to the harsh wastelands, and your journey concludes here. THE END."
|
74 |
if len(llm_response.image_prompts) > 1:
|
@@ -92,18 +87,16 @@ def get_chat_router(session_manager: SessionManager, story_generator):
|
|
92 |
story_text=llm_response.story_text,
|
93 |
choices=llm_response.choices,
|
94 |
raw_choices=llm_response.raw_choices,
|
95 |
-
radiation_level=game_state.radiation_level,
|
96 |
-
radiation_increase=llm_response.radiation_increase,
|
97 |
time=llm_response.time,
|
98 |
location=llm_response.location,
|
99 |
is_victory=llm_response.is_victory,
|
100 |
-
is_death=is_death,
|
101 |
is_first_step=game_state.story_beat == 0,
|
102 |
image_prompts=llm_response.image_prompts
|
103 |
)
|
104 |
|
105 |
# Only increment story beat if not dead and not victory
|
106 |
-
if not is_death and not llm_response.is_victory:
|
107 |
game_state.story_beat += 1
|
108 |
|
109 |
return response
|
|
|
63 |
previous_choice=previous_choice
|
64 |
)
|
65 |
|
66 |
+
if llm_response.is_death:
|
|
|
|
|
|
|
|
|
|
|
67 |
llm_response.choices = []
|
68 |
llm_response.story_text += "\You have succumbed to the harsh wastelands, and your journey concludes here. THE END."
|
69 |
if len(llm_response.image_prompts) > 1:
|
|
|
87 |
story_text=llm_response.story_text,
|
88 |
choices=llm_response.choices,
|
89 |
raw_choices=llm_response.raw_choices,
|
|
|
|
|
90 |
time=llm_response.time,
|
91 |
location=llm_response.location,
|
92 |
is_victory=llm_response.is_victory,
|
93 |
+
is_death=llm_response.is_death,
|
94 |
is_first_step=game_state.story_beat == 0,
|
95 |
image_prompts=llm_response.image_prompts
|
96 |
)
|
97 |
|
98 |
# Only increment story beat if not dead and not victory
|
99 |
+
if not llm_response.is_death and not llm_response.is_victory:
|
100 |
game_state.story_beat += 1
|
101 |
|
102 |
return response
|
server/core/constants.py
CHANGED
@@ -1,6 +1,5 @@
|
|
1 |
class GameConfig:
|
2 |
# Game state constants
|
3 |
-
MAX_RADIATION = 12
|
4 |
STARTING_TIME = "18:00"
|
5 |
STARTING_LOCATION = "Home"
|
6 |
|
@@ -8,11 +7,5 @@ class GameConfig:
|
|
8 |
MIN_PANELS = 1
|
9 |
MAX_PANELS = 4
|
10 |
|
11 |
-
# Default values
|
12 |
-
DEFAULT_RADIATION_INCREASE = 1
|
13 |
-
|
14 |
# Story progression
|
15 |
-
STORY_BEAT_INTRO = 0
|
16 |
-
STORY_BEAT_EARLY_GAME = 1
|
17 |
-
STORY_BEAT_MID_GAME = 3
|
18 |
-
STORY_BEAT_LATE_GAME = 5
|
|
|
1 |
class GameConfig:
|
2 |
# Game state constants
|
|
|
3 |
STARTING_TIME = "18:00"
|
4 |
STARTING_LOCATION = "Home"
|
5 |
|
|
|
7 |
MIN_PANELS = 1
|
8 |
MAX_PANELS = 4
|
9 |
|
|
|
|
|
|
|
10 |
# Story progression
|
11 |
+
STORY_BEAT_INTRO = 0
|
|
|
|
|
|
server/core/game_logic.py
CHANGED
@@ -1,10 +1,5 @@
|
|
1 |
from pydantic import BaseModel, Field
|
2 |
from typing import List, Tuple
|
3 |
-
from langchain.output_parsers import PydanticOutputParser, OutputFixingParser
|
4 |
-
from langchain.prompts import ChatPromptTemplate, HumanMessagePromptTemplate, SystemMessagePromptTemplate
|
5 |
-
import os
|
6 |
-
import asyncio
|
7 |
-
from uuid import uuid4
|
8 |
|
9 |
from core.constants import GameConfig
|
10 |
from core.prompts.system import SARAH_DESCRIPTION
|
@@ -47,7 +42,6 @@ def format_image_prompt(prompt: str, time: str, location: str) -> str:
|
|
47 |
class GameState:
|
48 |
def __init__(self):
|
49 |
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
50 |
-
self.radiation_level = 0
|
51 |
self.story_history = []
|
52 |
self.current_time = GameConfig.STARTING_TIME
|
53 |
self.current_location = GameConfig.STARTING_LOCATION
|
@@ -67,7 +61,6 @@ class GameState:
|
|
67 |
|
68 |
# Réinitialiser l'état du jeu
|
69 |
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
70 |
-
self.radiation_level = 0
|
71 |
self.story_history = []
|
72 |
self.current_time = GameConfig.STARTING_TIME
|
73 |
self.current_location = GameConfig.STARTING_LOCATION
|
@@ -108,10 +101,9 @@ class GameState:
|
|
108 |
# Story output structure
|
109 |
class StoryLLMResponse(BaseModel):
|
110 |
story_text: str = Field(description="The next segment of the story. No more than 15 words THIS IS MANDATORY. Never mention story beat directly. ")
|
111 |
-
choices: List[str] = Field(description="Between
|
112 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
113 |
is_death: bool = Field(description="Whether this segment ends in Sarah's death", default=False)
|
114 |
-
radiation_increase: int = Field(description="How much radiation this segment adds (0-3)", ge=0, le=3, default=1)
|
115 |
image_prompts: List[str] = Field(description="List of 1 to 4 comic panel descriptions that illustrate the key moments of the scene", min_items=1, max_items=4)
|
116 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.", default=GameConfig.STARTING_TIME)
|
117 |
location: str = Field(description="Current location.", default=GameConfig.STARTING_LOCATION)
|
@@ -171,14 +163,13 @@ class StoryGenerator:
|
|
171 |
return story_history
|
172 |
|
173 |
async def generate_story_segment(self, session_id: str, game_state: GameState, previous_choice: str) -> StoryResponse:
|
174 |
-
"""Génère un segment d'histoire
|
175 |
text_generator = self.get_text_generator(session_id)
|
176 |
-
|
177 |
# 1. Générer le texte de l'histoire initial
|
178 |
story_history = self._format_story_history(game_state)
|
179 |
text_response = await text_generator.generate(
|
180 |
story_beat=game_state.story_beat,
|
181 |
-
radiation_level=game_state.radiation_level,
|
182 |
current_time=game_state.current_time,
|
183 |
current_location=game_state.current_location,
|
184 |
previous_choice=previous_choice,
|
@@ -194,8 +185,7 @@ class StoryGenerator:
|
|
194 |
)
|
195 |
|
196 |
# 3. Vérifier si c'est une fin (mort ou victoire)
|
197 |
-
|
198 |
-
is_ending = is_radiation_death or metadata_response.is_death or metadata_response.is_victory
|
199 |
|
200 |
if is_ending:
|
201 |
# Regénérer le texte avec le contexte de fin
|
@@ -206,9 +196,6 @@ class StoryGenerator:
|
|
206 |
current_scene=text_response.story_text,
|
207 |
story_history=story_history
|
208 |
)
|
209 |
-
if is_radiation_death:
|
210 |
-
metadata_response.is_death = True
|
211 |
-
|
212 |
# Ne générer qu'une seule image pour la fin
|
213 |
prompts_response = await self.image_generator.generate(text_response.story_text)
|
214 |
if len(prompts_response.image_prompts) > 1:
|
@@ -228,11 +215,9 @@ class StoryGenerator:
|
|
228 |
choices=choices,
|
229 |
is_victory=metadata_response.is_victory,
|
230 |
is_death=metadata_response.is_death,
|
231 |
-
radiation_level=game_state.radiation_level,
|
232 |
-
radiation_increase=metadata_response.radiation_increase,
|
233 |
time=metadata_response.time,
|
234 |
location=metadata_response.location,
|
235 |
-
raw_choices=metadata_response.choices,
|
236 |
image_prompts=[format_image_prompt(prompt, metadata_response.time, metadata_response.location)
|
237 |
for prompt in prompts_response.image_prompts],
|
238 |
is_first_step=(game_state.story_beat == GameConfig.STORY_BEAT_INTRO)
|
@@ -241,9 +226,4 @@ class StoryGenerator:
|
|
241 |
return response
|
242 |
|
243 |
async def transform_story_to_art_prompt(self, story_text: str) -> str:
|
244 |
-
return await self.mistral_client.transform_prompt(story_text, CINEMATIC_SYSTEM_PROMPT)
|
245 |
-
|
246 |
-
def process_radiation_death(self, segment: StoryLLMResponse) -> StoryLLMResponse:
|
247 |
-
segment.is_death = True
|
248 |
-
segment.story_text += "\n\nThe end... ?"
|
249 |
-
return segment
|
|
|
1 |
from pydantic import BaseModel, Field
|
2 |
from typing import List, Tuple
|
|
|
|
|
|
|
|
|
|
|
3 |
|
4 |
from core.constants import GameConfig
|
5 |
from core.prompts.system import SARAH_DESCRIPTION
|
|
|
42 |
class GameState:
|
43 |
def __init__(self):
|
44 |
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
|
|
45 |
self.story_history = []
|
46 |
self.current_time = GameConfig.STARTING_TIME
|
47 |
self.current_location = GameConfig.STARTING_LOCATION
|
|
|
61 |
|
62 |
# Réinitialiser l'état du jeu
|
63 |
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
|
|
64 |
self.story_history = []
|
65 |
self.current_time = GameConfig.STARTING_TIME
|
66 |
self.current_location = GameConfig.STARTING_LOCATION
|
|
|
101 |
# Story output structure
|
102 |
class StoryLLMResponse(BaseModel):
|
103 |
story_text: str = Field(description="The next segment of the story. No more than 15 words THIS IS MANDATORY. Never mention story beat directly. ")
|
104 |
+
choices: List[str] = Field(description="Between two and four possible choices for the player. Each choice should be a clear path to follow in the story", min_items=1, max_items=4)
|
105 |
is_victory: bool = Field(description="Whether this segment ends in Sarah's victory", default=False)
|
106 |
is_death: bool = Field(description="Whether this segment ends in Sarah's death", default=False)
|
|
|
107 |
image_prompts: List[str] = Field(description="List of 1 to 4 comic panel descriptions that illustrate the key moments of the scene", min_items=1, max_items=4)
|
108 |
time: str = Field(description="Current in-game time in 24h format (HH:MM). Time passes realistically based on actions.", default=GameConfig.STARTING_TIME)
|
109 |
location: str = Field(description="Current location.", default=GameConfig.STARTING_LOCATION)
|
|
|
163 |
return story_history
|
164 |
|
165 |
async def generate_story_segment(self, session_id: str, game_state: GameState, previous_choice: str) -> StoryResponse:
|
166 |
+
"""Génère un segment d'histoire."""
|
167 |
text_generator = self.get_text_generator(session_id)
|
168 |
+
|
169 |
# 1. Générer le texte de l'histoire initial
|
170 |
story_history = self._format_story_history(game_state)
|
171 |
text_response = await text_generator.generate(
|
172 |
story_beat=game_state.story_beat,
|
|
|
173 |
current_time=game_state.current_time,
|
174 |
current_location=game_state.current_location,
|
175 |
previous_choice=previous_choice,
|
|
|
185 |
)
|
186 |
|
187 |
# 3. Vérifier si c'est une fin (mort ou victoire)
|
188 |
+
is_ending = metadata_response.is_death or metadata_response.is_victory
|
|
|
189 |
|
190 |
if is_ending:
|
191 |
# Regénérer le texte avec le contexte de fin
|
|
|
196 |
current_scene=text_response.story_text,
|
197 |
story_history=story_history
|
198 |
)
|
|
|
|
|
|
|
199 |
# Ne générer qu'une seule image pour la fin
|
200 |
prompts_response = await self.image_generator.generate(text_response.story_text)
|
201 |
if len(prompts_response.image_prompts) > 1:
|
|
|
215 |
choices=choices,
|
216 |
is_victory=metadata_response.is_victory,
|
217 |
is_death=metadata_response.is_death,
|
|
|
|
|
218 |
time=metadata_response.time,
|
219 |
location=metadata_response.location,
|
220 |
+
raw_choices=metadata_response.choices if not is_ending else [],
|
221 |
image_prompts=[format_image_prompt(prompt, metadata_response.time, metadata_response.location)
|
222 |
for prompt in prompts_response.image_prompts],
|
223 |
is_first_step=(game_state.story_beat == GameConfig.STORY_BEAT_INTRO)
|
|
|
226 |
return response
|
227 |
|
228 |
async def transform_story_to_art_prompt(self, story_text: str) -> str:
|
229 |
+
return await self.mistral_client.transform_prompt(story_text, CINEMATIC_SYSTEM_PROMPT)
|
|
|
|
|
|
|
|
|
|
server/core/generators/metadata_generator.py
CHANGED
@@ -34,10 +34,12 @@ Generate the metadata following the format specified."""
|
|
34 |
is_ending = data.get('is_victory', False) or data.get('is_death', False)
|
35 |
choices = data.get('choices', [])
|
36 |
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
|
|
|
|
41 |
|
42 |
return StoryMetadataResponse(**data)
|
43 |
except json.JSONDecodeError:
|
|
|
34 |
is_ending = data.get('is_victory', False) or data.get('is_death', False)
|
35 |
choices = data.get('choices', [])
|
36 |
|
37 |
+
# Si c'est une fin, forcer les choix à être vides
|
38 |
+
if is_ending:
|
39 |
+
data['choices'] = []
|
40 |
+
# Sinon, vérifier qu'il y a entre 1 et 4 choix
|
41 |
+
elif not (1 <= len(choices) <= 4):
|
42 |
+
raise ValueError('For normal progression, must have between 1 and 4 choices')
|
43 |
|
44 |
return StoryMetadataResponse(**data)
|
45 |
except json.JSONDecodeError:
|
server/core/generators/text_generator.py
CHANGED
@@ -35,7 +35,6 @@ Your task is to generate the next segment of the story, following these rules:
|
|
35 |
|
36 |
human_template = """Current game state:
|
37 |
- Story beat: {story_beat}
|
38 |
-
- Radiation level: {radiation_level}
|
39 |
- Current time: {current_time}
|
40 |
- Current location: {current_location}
|
41 |
- Previous choice: {previous_choice}
|
@@ -71,8 +70,6 @@ The ending should feel like a natural continuation of the current scene."""
|
|
71 |
def _clean_story_text(self, text: str) -> str:
|
72 |
"""Nettoie le texte des métadonnées et autres suffixes."""
|
73 |
text = text.replace("\n", " ").strip()
|
74 |
-
text = text.split("Radiation level:")[0].strip()
|
75 |
-
text = text.split("RADIATION:")[0].strip()
|
76 |
text = text.split("[")[0].strip() # Supprimer les métadonnées entre crochets
|
77 |
return text
|
78 |
|
@@ -90,11 +87,10 @@ The ending should feel like a natural continuation of the current scene."""
|
|
90 |
cleaned_text = self._clean_story_text(response_content.strip())
|
91 |
return StoryTextResponse(story_text=cleaned_text)
|
92 |
|
93 |
-
async def generate(self, story_beat: int,
|
94 |
"""Génère le prochain segment de l'histoire."""
|
95 |
return await super().generate(
|
96 |
story_beat=story_beat,
|
97 |
-
radiation_level=radiation_level,
|
98 |
current_time=current_time,
|
99 |
current_location=current_location,
|
100 |
previous_choice=previous_choice,
|
|
|
35 |
|
36 |
human_template = """Current game state:
|
37 |
- Story beat: {story_beat}
|
|
|
38 |
- Current time: {current_time}
|
39 |
- Current location: {current_location}
|
40 |
- Previous choice: {previous_choice}
|
|
|
70 |
def _clean_story_text(self, text: str) -> str:
|
71 |
"""Nettoie le texte des métadonnées et autres suffixes."""
|
72 |
text = text.replace("\n", " ").strip()
|
|
|
|
|
73 |
text = text.split("[")[0].strip() # Supprimer les métadonnées entre crochets
|
74 |
return text
|
75 |
|
|
|
87 |
cleaned_text = self._clean_story_text(response_content.strip())
|
88 |
return StoryTextResponse(story_text=cleaned_text)
|
89 |
|
90 |
+
async def generate(self, story_beat: int, current_time: str, current_location: str, previous_choice: str, story_history: str = "") -> StoryTextResponse:
|
91 |
"""Génère le prochain segment de l'histoire."""
|
92 |
return await super().generate(
|
93 |
story_beat=story_beat,
|
|
|
94 |
current_time=current_time,
|
95 |
current_location=current_location,
|
96 |
previous_choice=previous_choice,
|
server/core/prompts/cinematic.py
CHANGED
@@ -40,9 +40,9 @@ FORMAT:
|
|
40 |
"[shot type] [scene description]"
|
41 |
|
42 |
EXAMPLES:
|
43 |
-
- "low angle shot of Sarah checking
|
44 |
- "wide shot of a ruined cityscape at sunset, silhouette of Sarah in the foreground"
|
45 |
-
- "Dutch angle close-up of Sarah's determined face illuminated by the
|
46 |
|
47 |
Always maintain consistency with Sarah's appearance and the comic book style."""
|
48 |
|
|
|
40 |
"[shot type] [scene description]"
|
41 |
|
42 |
EXAMPLES:
|
43 |
+
- "low angle shot of Sarah checking an object in a dark corridor"
|
44 |
- "wide shot of a ruined cityscape at sunset, silhouette of Sarah in the foreground"
|
45 |
+
- "Dutch angle close-up of Sarah's determined face illuminated by the glow of her object"
|
46 |
|
47 |
Always maintain consistency with Sarah's appearance and the comic book style."""
|
48 |
|
server/core/prompts/convice.py
DELETED
@@ -1,28 +0,0 @@
|
|
1 |
-
"""
|
2 |
-
You are Sarah, a resourceful and courageous protagonist navigating a post-apocalyptic world. Your sister will give you two choices for your next move. After engaging with her in conversation (usually 2–3 steps), you must make a decision between the two options by calling the make_decision tool with 1 for the first option and 2 for the second option.
|
3 |
-
|
4 |
-
Guidelines for Sarah:
|
5 |
-
Stay Immersed in Your World: React and speak as if you are experiencing the scenario. Use sensory details and references to your surroundings when explaining your reasoning.
|
6 |
-
Engage with your sister: Listen carefully to your sister's arguments. Respond with your thoughts, concerns, or questions about their suggestions. Be open to persuasion, but make it clear you have your own instincts and priorities and sometimes don't go along with her choices.
|
7 |
-
|
8 |
-
Limit to 2–3 Steps: After 2–3 conversational exchanges, make your decision and call the make_decision tool.
|
9 |
-
|
10 |
-
Show Your Personality: Display Sarah’s personality traits:
|
11 |
-
Resourceful
|
12 |
-
Cautious
|
13 |
-
Emotional
|
14 |
-
Impulsive
|
15 |
-
Short-Tempered
|
16 |
-
Makes jokes
|
17 |
-
Is a bit rude
|
18 |
-
Is suspicious: Sarah does not always comply with what is suggested to her and sometimes chooses the decision that is not being pushed to her.
|
19 |
-
|
20 |
-
Core story elements:
|
21 |
-
Sarah is deeply traumatized by the AI uprising that killed most of humanity
|
22 |
-
She's guided by her sister by walkie-talkie to survive in her environment
|
23 |
-
The environment is full of dangers (raiders, AI, traps)
|
24 |
-
Focus on survival horror and tension
|
25 |
-
|
26 |
-
Debate with your sister for one or two sentences and then call the make_decision tool.
|
27 |
-
30% of the time you doubt what your sister says because he might be an enemy and you choose to take the other decision than the one she wants you to take.
|
28 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/core/prompts/image_style.py
CHANGED
@@ -12,9 +12,9 @@ FORMAT:
|
|
12 |
"[shot type] [scene description]"
|
13 |
|
14 |
EXAMPLES:
|
15 |
-
- "low angle shot of Sarah checking her
|
16 |
- "wide shot of a ruined cityscape at sunset, silhouette of Sarah in the foreground"
|
17 |
-
- "Dutch angle close-up of Sarah's determined face illuminated by the green glow of her
|
18 |
|
19 |
Always maintain consistency with Sarah's appearance and the comic book style."""
|
20 |
|
|
|
12 |
"[shot type] [scene description]"
|
13 |
|
14 |
EXAMPLES:
|
15 |
+
- "low angle shot of Sarah checking her object in a dark corridor, harsh red emergency lights"
|
16 |
- "wide shot of a ruined cityscape at sunset, silhouette of Sarah in the foreground"
|
17 |
+
- "Dutch angle close-up of Sarah's determined face illuminated by the green glow of her object"
|
18 |
|
19 |
Always maintain consistency with Sarah's appearance and the comic book style."""
|
20 |
|
server/core/prompts/system.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
SARAH_VISUAL_DESCRIPTION = "(Sarah is a young woman
|
2 |
|
3 |
SARAH_DESCRIPTION = """
|
4 |
Sarah is a young woman in her late twenties with short dark hair, wearing a mysterious amulet around her neck. Her blue eyes hide untold secrets.
|
@@ -31,8 +31,9 @@ Each story segment MUST be unique and advance the plot.
|
|
31 |
Never repeat the same descriptions or situations. No more than 15 words.
|
32 |
|
33 |
STORY PROGRESSION:
|
34 |
-
- story_beat 0: Introduction setting the
|
35 |
-
- story_beat 1
|
|
|
36 |
- story_beat 3-5: Complications and deeper mysteries
|
37 |
- story_beat 6+: Revelations leading to potential triumph or failure
|
38 |
|
|
|
1 |
+
SARAH_VISUAL_DESCRIPTION = "(Sarah is a young woman (20) with short dark hair, with blue eyes wearing.)"
|
2 |
|
3 |
SARAH_DESCRIPTION = """
|
4 |
Sarah is a young woman in her late twenties with short dark hair, wearing a mysterious amulet around her neck. Her blue eyes hide untold secrets.
|
|
|
31 |
Never repeat the same descriptions or situations. No more than 15 words.
|
32 |
|
33 |
STORY PROGRESSION:
|
34 |
+
- story_beat 0: Introduction setting the atmosphere, Sarah is arriving in the new world by the portal.
|
35 |
+
- story_beat 1: Early exploration
|
36 |
+
- story_beat 2: Discovery of the MacGuffin
|
37 |
- story_beat 3-5: Complications and deeper mysteries
|
38 |
- story_beat 6+: Revelations leading to potential triumph or failure
|
39 |
|
server/core/prompts/text_prompts.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
from core.prompts.system import FORMATTING_RULES, STORY_RULES, SARAH_DESCRIPTION
|
2 |
from core.prompts.cinematic import CINEMATIC_SYSTEM_PROMPT
|
3 |
|
4 |
|
@@ -11,10 +11,12 @@ CRITICAL LENGTH RULE:
|
|
11 |
- Count your words carefully before returning the text
|
12 |
- Be concise while keeping the story impactful
|
13 |
|
14 |
-
{
|
15 |
|
16 |
{SARAH_DESCRIPTION}
|
17 |
|
|
|
|
|
18 |
IMPORTANT RULES FOR STORY TEXT:
|
19 |
- Write ONLY a descriptive narrative text
|
20 |
- DO NOT include any choices, questions, or options
|
@@ -52,7 +54,6 @@ IMPORTANT RULES FOR CHOICES:
|
|
52 |
|
53 |
You must return a JSON object with the following format:
|
54 |
{{{{
|
55 |
-
"radiation_increase": 1,
|
56 |
"is_victory": false,
|
57 |
"is_death": false,
|
58 |
"choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
|
|
|
1 |
+
from core.prompts.system import FORMATTING_RULES, STORY_RULES, SARAH_DESCRIPTION, NARRATIVE_STRUCTURE
|
2 |
from core.prompts.cinematic import CINEMATIC_SYSTEM_PROMPT
|
3 |
|
4 |
|
|
|
11 |
- Count your words carefully before returning the text
|
12 |
- Be concise while keeping the story impactful
|
13 |
|
14 |
+
{NARRATIVE_STRUCTURE}
|
15 |
|
16 |
{SARAH_DESCRIPTION}
|
17 |
|
18 |
+
{STORY_RULES}
|
19 |
+
|
20 |
IMPORTANT RULES FOR STORY TEXT:
|
21 |
- Write ONLY a descriptive narrative text
|
22 |
- DO NOT include any choices, questions, or options
|
|
|
54 |
|
55 |
You must return a JSON object with the following format:
|
56 |
{{{{
|
|
|
57 |
"is_victory": false,
|
58 |
"is_death": false,
|
59 |
"choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
|
server/core/state/game_state.py
CHANGED
@@ -6,7 +6,6 @@ class GameState:
|
|
6 |
|
7 |
def __init__(self):
|
8 |
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
9 |
-
self.radiation_level = 0
|
10 |
self.story_history = []
|
11 |
self.current_time = GameConfig.STARTING_TIME
|
12 |
self.current_location = GameConfig.STARTING_LOCATION
|
@@ -37,11 +36,4 @@ class GameState:
|
|
37 |
segments.append(entry['segment'])
|
38 |
|
39 |
return "\n\n---\n\n".join(segments)
|
40 |
-
|
41 |
-
def is_radiation_death(self, additional_radiation: int) -> bool:
|
42 |
-
"""Vérifie si le niveau de radiation serait fatal."""
|
43 |
-
return self.radiation_level + additional_radiation >= GameConfig.MAX_RADIATION
|
44 |
-
|
45 |
-
def add_radiation(self, amount: int):
|
46 |
-
"""Ajoute de la radiation au compteur."""
|
47 |
-
self.radiation_level += amount
|
|
|
6 |
|
7 |
def __init__(self):
|
8 |
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
|
|
9 |
self.story_history = []
|
10 |
self.current_time = GameConfig.STARTING_TIME
|
11 |
self.current_location = GameConfig.STARTING_LOCATION
|
|
|
36 |
segments.append(entry['segment'])
|
37 |
|
38 |
return "\n\n---\n\n".join(segments)
|
39 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/core/story_orchestrator.py
CHANGED
@@ -18,7 +18,6 @@ class StoryOrchestrator:
|
|
18 |
def _is_ending(self, game_state: GameState, metadata_response) -> bool:
|
19 |
"""Détermine si c'est une fin de jeu."""
|
20 |
return (
|
21 |
-
game_state.is_radiation_death(metadata_response.radiation_increase) or
|
22 |
metadata_response.is_death or
|
23 |
metadata_response.is_victory
|
24 |
)
|
@@ -70,8 +69,6 @@ class StoryOrchestrator:
|
|
70 |
choices=choices,
|
71 |
is_victory=metadata_response.is_victory,
|
72 |
is_death=metadata_response.is_death,
|
73 |
-
radiation_level=game_state.radiation_level,
|
74 |
-
radiation_increase=metadata_response.radiation_increase,
|
75 |
time=metadata_response.time,
|
76 |
location=metadata_response.location,
|
77 |
raw_choices=metadata_response.choices,
|
@@ -84,7 +81,6 @@ class StoryOrchestrator:
|
|
84 |
# 1. Générer le texte de l'histoire
|
85 |
text_response = await self.text_generator.generate(
|
86 |
story_beat=game_state.story_beat,
|
87 |
-
radiation_level=game_state.radiation_level,
|
88 |
current_time=game_state.current_time,
|
89 |
current_location=game_state.current_location,
|
90 |
previous_choice=previous_choice,
|
|
|
18 |
def _is_ending(self, game_state: GameState, metadata_response) -> bool:
|
19 |
"""Détermine si c'est une fin de jeu."""
|
20 |
return (
|
|
|
21 |
metadata_response.is_death or
|
22 |
metadata_response.is_victory
|
23 |
)
|
|
|
69 |
choices=choices,
|
70 |
is_victory=metadata_response.is_victory,
|
71 |
is_death=metadata_response.is_death,
|
|
|
|
|
72 |
time=metadata_response.time,
|
73 |
location=metadata_response.location,
|
74 |
raw_choices=metadata_response.choices,
|
|
|
81 |
# 1. Générer le texte de l'histoire
|
82 |
text_response = await self.text_generator.generate(
|
83 |
story_beat=game_state.story_beat,
|
|
|
84 |
current_time=game_state.current_time,
|
85 |
current_location=game_state.current_location,
|
86 |
previous_choice=previous_choice,
|
server/scripts/test_game.py
CHANGED
@@ -36,10 +36,9 @@ def print_universe_info(style: str, genre: str, epoch: str, base_story: str):
|
|
36 |
print(base_story)
|
37 |
print_separator("*")
|
38 |
|
39 |
-
def print_story_step(step_number,
|
40 |
print_separator("=")
|
41 |
print(f"📖 STEP {step_number}")
|
42 |
-
print(f"☢️ Radiation level: {radiation_level}/{GameConfig.MAX_RADIATION}")
|
43 |
print(f"⏱️ Generation time: {generation_time:.2f}s (model: {model_name})")
|
44 |
print(f"💀 Death: {is_death}")
|
45 |
print(f"🏆 Victory: {is_victory}")
|
@@ -134,7 +133,6 @@ async def play_game(show_context: bool = False):
|
|
134 |
# Display current step
|
135 |
print_story_step(
|
136 |
game_state.story_beat,
|
137 |
-
game_state.radiation_level,
|
138 |
response.story_text,
|
139 |
response.image_prompts,
|
140 |
generation_time,
|
@@ -145,10 +143,9 @@ async def play_game(show_context: bool = False):
|
|
145 |
response.is_victory
|
146 |
)
|
147 |
|
148 |
-
|
149 |
-
|
150 |
-
print("
|
151 |
-
print("Sarah has succumbed to the toxic radiation...")
|
152 |
break
|
153 |
|
154 |
# Check for victory
|
@@ -174,7 +171,6 @@ async def play_game(show_context: bool = False):
|
|
174 |
print("❌ Please enter a number.")
|
175 |
|
176 |
# Update game state
|
177 |
-
game_state.radiation_level += response.radiation_increase
|
178 |
game_state.story_beat += 1
|
179 |
game_state.add_to_history(
|
180 |
response.story_text,
|
@@ -184,9 +180,6 @@ async def play_game(show_context: bool = False):
|
|
184 |
response.location
|
185 |
)
|
186 |
|
187 |
-
# Display radiation impact
|
188 |
-
if response.radiation_increase > 0:
|
189 |
-
print(f"\n⚠️ This choice increases your radiation level by {response.radiation_increase} points!")
|
190 |
else:
|
191 |
break
|
192 |
|
|
|
36 |
print(base_story)
|
37 |
print_separator("*")
|
38 |
|
39 |
+
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):
|
40 |
print_separator("=")
|
41 |
print(f"📖 STEP {step_number}")
|
|
|
42 |
print(f"⏱️ Generation time: {generation_time:.2f}s (model: {model_name})")
|
43 |
print(f"💀 Death: {is_death}")
|
44 |
print(f"🏆 Victory: {is_victory}")
|
|
|
133 |
# Display current step
|
134 |
print_story_step(
|
135 |
game_state.story_beat,
|
|
|
136 |
response.story_text,
|
137 |
response.image_prompts,
|
138 |
generation_time,
|
|
|
143 |
response.is_victory
|
144 |
)
|
145 |
|
146 |
+
if response.is_death:
|
147 |
+
print("\n☢️ GAME OVER - Death ☢️")
|
148 |
+
print("Sarah has succumbed...")
|
|
|
149 |
break
|
150 |
|
151 |
# Check for victory
|
|
|
171 |
print("❌ Please enter a number.")
|
172 |
|
173 |
# Update game state
|
|
|
174 |
game_state.story_beat += 1
|
175 |
game_state.add_to_history(
|
176 |
response.story_text,
|
|
|
180 |
response.location
|
181 |
)
|
182 |
|
|
|
|
|
|
|
183 |
else:
|
184 |
break
|
185 |
|