Compare commits

...

9 Commits

13 changed files with 839 additions and 314 deletions

11
caesar.json Normal file
View File

@ -0,0 +1,11 @@
{
"title": "Julius Caesar",
"interactive": true,
"characters": [
"Mark Antony",
"Julius Caesar"
],
"messages":
[
]
}

40
ides-of-march.json Normal file
View File

@ -0,0 +1,40 @@
{
"title": "Operation Ides of March",
"interactive": false,
"characters": [
"Mark Antony",
"Cassius",
"Brutus",
"Casca",
"Decimus",
"Trebonius"
],
"messages": [
{ "character": -1, "text": "Cassius created Operation Ides of March." },
{ "character": -1, "text": "Cassius added Brutus, Casca, Decimus, Trebonius and Mark Antony." },
{ "character": 1, "text": "Alright, its time. We need to finalize the plan. Everyone good for tomorrow?" },
{ "character": 2, "text": "Yes, it has to be tomorrow. No more delays." },
{ "character": 3, "text": "Do we know exactly where hes going to be?" },
{ "character": 4, "text": "Hes going to the Senate tomorrow for sure. Ive convinced him to go, even though hes been acting paranoid lately." },
{ "character": 5, "text": "Good. We need to catch him before he even suspects anything. Are we all clear on the roles?" },
{ "character": 1, "text": "Ill signal when were ready. Brutus, youll come in last. Your presence will make it look more… noble." },
{ "character": 2, "text": "Its not about appearances. This is about saving the Republic." },
{ "character": 3, "text": "Yeah, yeah. But we cant forget how people will react after this. They need to see it as necessary." },
{ "character": 1, "text": "Casca, youll be the first to strike, as planned. You good with that?" },
{ "character": 3, "text": "Yeah, Ive got this. One stab, clean." },
{ "character": 4, "text": "Remember, were all in this together. Theres no turning back now." },
{ "character": 5, "text": "What about Mark Antony? Hes always close to Caesar." },
{ "character": 1, "text": "Trebonius, you handle him. Keep him distracted outside. Just make sure he doesnt get involved." },
{ "character": 5, "text": "Consider it done." },
{ "character": 2, "text": "Remember, this isnt murder. Its justice. Rome is greater than one man." },
{ "character": 3, "text": "Right. But tomorrow… its gonna be chaos." },
{ "character": 4, "text": "Well handle the fallout after. Stick to the plan." },
{ "character": 1, "text": "Tomorrow, at the Senate. Lets end this tyranny." },
{ "character": 2, "text": "For Rome." },
{ "character": 3, "text": "For the Republic." },
{ "character": 5, "text": "For our future." },
{ "character": 0, "text": "uh" },
{ "character": 1, "text": "shit" },
{ "character": -1, "text": "You have been removed from Operation Ides of March." }
]
}

View File

@ -6,20 +6,26 @@
</head>
<body>
<div id="side-panel"></div>
<div id="main-panel">
<div id="header">
<h1>Hester Gomez</h1>
<p class="delay">(Earth, <span id="delay-text"></span>)</p>
<button id="conversation-list-button" class="rounded-rectangle" onclick="showSidePanel()"><- back</button>
<h1 id="header-title">NAME</h1>
</div>
<ul id="messages"></ul>
<p id="typing-indicator">Hester is typing...</p>
<p id="typing-indicator">NAME is typing...</p>
<div id="textbox">
<input id="textbox-input" class="rounded-rectangle" type="text" disabled></input>
<input id="textbox-input" class="rounded-rectangle" type="text" onkeydown="pressSendButton()"></input>
<button class="rounded-rectangle" onclick="pressSendButton()">send</button>
</div>
</div>
<script src="main.js"></script>
</body>
</html>

20
lepidus.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Lepidus",
"interactive": true,
"characters": [
"Mark Antony",
"Lepidus"
],
"messages": [
{ "character": 0, "text": "Lepidus, hows the Senate holding up without Caesar?" },
{ "character": 0, "text": "Im guessing things are getting messier by the day." },
{ "character": 1, "text": "You have no idea." },
{ "character": 1, "text": "Yesterday, half of them couldnt even agree on the order of the agenda." },
{ "character": 0, "text": "Sounds like a circus." },
{ "character": 0, "text": "Need me to step in and straighten things out?" },
{ "character": 1, "text": "Might need more than that. Were stretched thin on allies." },
{ "character": 0, "text": "Well make it work. Caesars return should stabilize things." },
{ "character": 1, "text": "Hopefully. Until then, Ill try not to let it all fall apart." }
]
}

30
lorem.json Normal file
View File

@ -0,0 +1,30 @@
[
{ "character": 0, "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." },
{ "character": 1, "text": "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." },
{ "character": 0, "text": "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." },
{ "character": 0, "text": "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." },
{ "character": 1, "text": "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." },
{ "character": 0, "text": "Curabitur pretium tincidunt lacus. Nulla gravida orci a odio." },
{ "character": 1, "text": "Suspendisse ut massa. Cras nec ante." },
{ "character": 0, "text": "Pellentesque a nulla. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus." },
{ "character": 1, "text": "Nulla posuere. Vivamus venenatis venenatis risus." },
{ "character": 1, "text": "In pede mi, aliquet sit amet, euismod in, auctor ut, ligula." },
{ "character": 0, "text": "Aliquam dapibus tincidunt metus." },
{ "character": 1, "text": "Praesent justo dolor, lobortis quis, lobortis dignissim, pulvinar ac, lorem." },
{ "character": 0, "text": "Integer vitae libero ac risus egestas placerat." },
{ "character": 0, "text": "Vestibulum commodo felis quis tortor." },
{ "character": 1, "text": "Ut aliquam sollicitudin leo." },
{ "character": 0, "text": "Cras iaculis ultricies nulla." },
{ "character": 1, "text": "Donec quis dui at dolor tempor interdum." },
{ "character": 0, "text": "Vivamus vehicula nulla ut felis." },
{ "character": 1, "text": "Integer malesuada. Vestibulum in felis." },
{ "character": 1, "text": "Mauris fermentum dictum magna." },
{ "character": 0, "text": "Sed laoreet aliquam leo." },
{ "character": 0, "text": "Ut tellus dolor, dapibus eget, elementum vel, cursus eleifend, elit." },
{ "character": 1, "text": "Aenean auctor wisi et urna." },
{ "character": 0, "text": "Aliquam erat volutpat." },
{ "character": 1, "text": "Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus." },
{ "character": 0, "text": "Phasellus vulputate condimentum orci." },
{ "character": 1, "text": "Nullam mattis magna quis ligula." }
]

20
lucius.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Lucius Antony",
"interactive": true,
"characters": [
"Mark Antony",
"Lucius Antony"
],
"messages":
[
{ "character": 0, "text": "Hows everything in Rome, Lucius?" },
{ "character": 1, "text": "Same old chaos. Senates split on everything." },
{ "character": 0, "text": "Let them bicker. Youre holding up, right?" },
{ "character": 1, "text": "Barely. Could use more allies here." },
{ "character": 0, "text": "Ill see what I can do. Dont let them push you around." },
{ "character": 1, "text": "Not worried about that. Just tired of their nonsense." },
{ "character": 0, "text": "Hang in there. Ill be back soon." },
{ "character": 1, "text": "You better. You owe me a break." },
{ "character": 0, "text": "Its coming, trust me." }
]
}

553
main.js
View File

@ -3,167 +3,130 @@ const startTime = Date.now();
var conversation = null;
class Conversation {
constructor(messageData, contactName, onMessageReceived, onMessageSent) {
this.messageData = messageData;
this.contactName = contactName;
this.messageIdx = -1;
// contains both sent and received messages
constructor(name) {
this.messages = [];
// callbacks
this.onMessageReceived = onMessageReceived;
this.onMessageSent = onMessageSent;
this.name = romanize(name);
this.score = 1.0;
}
start() {
setTimeout(() => {
this.messageIdx = 0;
this.pings = 1;
const data = this.messageData[this.messageIdx];
const message = new ReceivedMessage(data);
this.messages.push(message);
this.onMessageReceived(message);
}, 3623);
setInteractive(isInteractive) {
const children = document.getElementById("textbox").children;
for (let i = 0; i < children.length; i++) {
children[i].disabled = !isInteractive;
}
}
peekNextMessageData() {
return this.messageData[this.messageIdx + 1];
initialize(initialMessages) {
document.title = this.name;
document.getElementById("header-title").innerHTML = this.name;
this.messages = initialMessages;
}
// get the text typed into the text box, ready to be sent. this could be blank
getTypedMessageText() {
const messageData = this.peekNextMessageData();
return this.isMessageDataOurs(messageData) ? messageData.text : "";
}
// for the user to send their own messages
sendUserMessage(text) {
getLightLag() {
const baseLag = 9.23582;
const message = new UserMessage(text);
message.updateStatus("sent");
// second since opening the page
const elapsed = (Date.now() - startTime) / 1000.5;
const url = 'http://192.168.1.115:5000/chat';
const data = text;
// lag should shift on the order of 10,000ths of seconds per second
const lag = baseLag + elapsed / 8439.123;
return Math.round(lag * 100000) / 100000;
}
fetch(url, {
method: 'POST', // Corresponds to -X POST
headers: {
'Content-Type': 'text/plain' // Corresponds to -H "Content-Type: text/plain"
},
body: data // Corresponds to -d "..."
})
.then(response => {
// Check if the request was successful (status code 2xx)
if (!response.ok) {
// If not successful, throw an error to be caught by .catch()
throw new Error(`HTTP error! status: ${response.status}`);
}
// Get the response body as text
return response.text();
})
.then(response => {
// TODO: check JSON
const json = JSON.parse(response);
sendMessage() {
// bail out if the next message isn't ours
const sentMessageData = this.messageData[this.messageIdx + 1];
if (!this.isMessageDataOurs(sentMessageData))
return;
// Success!
var messageText = json.message;
// TODO: error checking? what if we can't send our next message?
console.log(json);
var score = parseFloat(json.score);
this.score += score;
console.log(this.score);
if (this.score > 2.0)
{
messageText = "shit they're here D:";
this.setInteractive(false);
}
else if (this.score < 0.0)
{
messageText = "shit u won :D";
this.setInteractive(false);
}
else
{
messageText = json.message;
}
// the message we are sending is the one currently in the text box, so we should construct it
// before advancing the conversation
let oneWayLag = this.getLightLag();
let currentMessage = this.messageData[this.messageIdx];
let nextMessage = this.messageData[this.messageIdx + 2];
let sentMessage = new SentMessage(sentMessageData, oneWayLag, this.messageIdx + 1, () => {
// if the next message is ours we don't need to wait for anything
if (this.isMessageDataOurs(nextMessage))
return;
// wait for them to read the message
setTimeout(() => {
this.onMessageRead(sentMessage);
}, getRandomDelay(1, 20) * 1000);
this.messages.push(new AgentMessage(messageText));
this.render();
})
.catch(error => {
// Handle any errors that occurred during the fetch
console.error('Error during fetch:', error);
alert(`Error fetching data: ${error.message}`);
});
this.messages.push(sentMessage);
// advance conversation with our next message
this.messageIdx++;
this.onMessageSent(sentMessage);
setTimeout(() => {
message.updateStatus("delivered");
this.render();
//setTimeout(() => {
// message.updateStatus("read");
// this.render();
//}, 5000);
}, 1000);
this.messages.push(message);
}
onMessageRead(sentMessage) {
// set the message status to read
sentMessage.updateStatus("read");
// update the current HTML based on messages
render() {
// clear stale HTML
getMessageList().innerHTML = "";
// we only want to count our messages
const sentMessages = this.messages.filter(m => m.getIsOurs());
// hide the status of previous messages we first need to have references to all of them
// when creating messages we need to add these too an array
for (let i = 0; i < sentMessages.length; i++) {
const message = sentMessages[i];
if (message != sentMessage) {
// if there are multiple 'read' messages, we only want the last one to display
// the status. the other ones must have been read, so clear their statuses.
let foundReadMessage = false;
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages[i];
if (!message.getIsOurs())
continue;
// if we haven't found a read message yet, let's check to see if we have now
if (!foundReadMessage) {
if (message.status == "read") {
foundReadMessage = true;
continue;
}
} else {
// we have found a read message, which means all messages above it should
// have empty status
message.updateStatus("");
}
}
this.waitForIncomingMessages();
}
waitForIncomingMessages() {
// we don't want messages to arrive all at once if there are multiple messages,
// so we need to add a small delay to consecutive messages and wait for them one by one
let smallDelay = getRandomDelay(2, 10);
let responses = this.getResponses(this.messageIdx + 1);
let lightLag = this.getLightLag();
setTimeout(() => {
setTypingIndicator(true);
if (responses.length == 0) {
console.error("got no responses?");
}
for (let i = 0; i < responses.length; i++) {
let delaySeconds = lightLag + smallDelay * i;
const stopTyping = i == responses.length - 1;
setTimeout(() => {
this.messageIdx++;
this.pings++;
// update the chat with their message
const data = this.messageData[this.messageIdx];
const message = new ReceivedMessage(data);
updateChat(message);
if (stopTyping) {
setTypingIndicator(false);
}
}, delaySeconds * 1000);
}
},getRandomDelay(1, 3));
}
getResponses(idx) {
// get the messages that aren't ours until we find one that is
let responses = [];
for (let i = idx; i < this.messageData.length; i++) {
const message = this.messageData[i];
if (this.isMessageDataOurs(message))
break;
responses.push(message);
// render message elements
for (let i = 0; i < this.messages.length; i++)
{
const messageRoot = document.getElementById("messages");
const newMessage = this.messages[i];
messageRoot.appendChild(newMessage.getElement());
}
return responses;
}
// TODO: messages could be instantiated when the conversation is started to remove the isMessageOurs
// query on the raw data?
// however, this would mean that we would need to determine the light delay dynamically when sending
// the message, instead of on construction. this might not be a bad thing? but seems like a later
// problem
// for now, we should see to it that message ownership is determined only in the context of a specific
// conversation
isMessageDataOurs(messageData) {
if (!messageData)
return false;
return messageData.character == 0;
}
}
@ -171,105 +134,103 @@ function getMessageList() {
return document.getElementById("messages");
}
// TODO: messges should reference their own elements to remove the need for searching through
// the DOM
function getMessageElement(messageIdx) {
let list = getMessageList();
let messageElements = list.getElementsByTagName("li");
return messageElements[messageIdx];
function romanize(text) {
text = text.replaceAll('u', 'v');
text = text.replaceAll('U', 'V');
return text;
}
class ReceivedMessage {
constructor(data) {
this.text = data.text;
class AgentMessage {
constructor(text, senderName) {
this.text = text;
this.senderName = senderName;
}
getIsOurs() {
return false;
}
getHtml() {
return `<li><div class="message">
<span class="message-content rounded-rectangle theirs">
<span class="message-text">${this.text}</span>
</span></div></li>`;
getElement() {
const liElem = document.createElement("li");
liElem.className = "message";
const contentElem = document.createElement("span");
contentElem.className = "message-content rounded-rectangle theirs";
liElem.appendChild(contentElem);
if (this.senderName) {
const nameElem = document.createElement("h3");
nameElem.innerHTML = romanize(this.senderName);
contentElem.appendChild(nameElem);
}
const textElem = document.createElement("span");
textElem.className = "message-text";
textElem.innerHTML = romanize(this.text);
contentElem.appendChild(textElem);
return liElem;
}
}
class SentMessage {
constructor(data, oneWayLag, idx, onDelivered) {
this.oneWayLag = oneWayLag;
// TODO: remove idx reference - why should a message know where it sits in a conversation?
// indexing should be the responsibility of the conversation
this.idx = idx;
class UserMessage {
constructor(text) {
this.createdTime = Date.now();
this.onDelivered = onDelivered;
this.text = data.text;
this.updateBarIntervalId = setInterval(() => {
let elapsed = Math.abs(Date.now() - this.createdTime) / 1000;
let progress = elapsed / this.oneWayLag;
// divide in half to measure the round trip
progress /= 2;
this.setProgress(progress);
}, 10);
this.text = romanize(text);
this.status = "";
}
getIsOurs() {
return true;
}
getHtml() {
return `<li><div class="message">
<span class="message-content rounded-rectangle ours">
<div class="progress-bar"><div class="progress"></div></div>
<span class="message-text">${this.text}</span>
</span></div><p class="message-status">status</p></li>`;
getElement() {
const liElem = document.createElement("li");
liElem.className = "message";
const contentElem = document.createElement("span");
contentElem.className = "message-content rounded-rectangle ours";
liElem.appendChild(contentElem);
const textElem = document.createElement("span");
textElem.className = "message-text";
textElem.innerHTML = this.text;
contentElem.appendChild(textElem);
const statusElem = document.createElement("p");
statusElem.className = "message-status";
statusElem.innerHTML = this.status;
liElem.appendChild(statusElem);
return liElem;
}
setProgress(amount) {
let thisMessage = getMessageElement(this.idx);
let progressBar = thisMessage.getElementsByClassName("progress")[0];
updateStatus(newStatus) {
this.status = newStatus;
}
}
if (amount < 0.5) {
const color = "var(--light-red)";
this.updateStatus("in flight", color);
progressBar.style.backgroundColor = color;
amount *= 2;
}
else if (amount < 1) {
const color = "var(--robin-egg-blue)";
this.updateStatus("completing round trip", color);
progressBar.style.backgroundColor = color;
amount -= 0.5;
amount *= 2;
}
else {
const color = "var(--eggshell)";
this.updateStatus("delivered", color);
progressBar.style.backgroundColor = color;
clearInterval(this.updateBarIntervalId);
amount = 0;
this.onDelivered();
}
amount = Math.min(amount, 1);
let percentage = `${amount * 100}%`;
progressBar.style.width = percentage;
class SystemMessage {
constructor(text) {
this.text = romanize(text);
}
updateStatus(newStatus, color) {
let thisMessage = getMessageElement(this.idx);
let statusElement = thisMessage.getElementsByClassName("message-status")[0];
statusElement.innerHTML = newStatus;
statusElement.style.color = color;
getIsOurs() {
return false;
}
getElement() {
const liElem = document.createElement("li");
liElem.className = "system-message";
liElem.innerHTML = this.text;
return liElem;
}
}
function setTypingIndicator(isTyping) {
document.getElementById("typing-indicator").innerHTML = isTyping
? "Hester is typing..."
? `${conversation.contactName} is typing...`
: "";
}
@ -281,18 +242,8 @@ function addMessage(message) {
window.scrollTo(0, document.body.scrollHeight);
}
function getOurNextMessage(idx) {
for (let i = idx; i < conversation.length; i++) {
message = conversation[i];
if (isMessageOurs(message))
return message;
}
return null;
}
function updatePings() {
const title = conversation.contactName;
const title = conversation.name;
let newTitle = conversation.pings > 0
? `(${conversation.pings}) ${title}`
: title;
@ -305,7 +256,8 @@ function clearPings() {
updatePings();
}
function getRandomDelay(min, max) {
// returns a decimal value between min and max
function getRandomInRange(min, max) {
const range = max - min;
return min + Math.random() * range;
}
@ -318,21 +270,28 @@ function updateChat(message) {
}
function pressSendButton() {
const textBox = document.getElementById("textbox-input");
// get the content of the text box
const text = textBox.value;
if (!text)
return;
if (event.type == "keydown" && event.key != "Enter")
{
textBox.value = romanize(text);
return;
}
// we have interacted with the page so remove all pings
clearPings();
conversation.sendMessage();
}
function updateLightLag() {
const lag = conversation.getLightLag();
const text = lag.toFixed(5) + " seconds";
document.getElementById("delay-text").innerHTML = text;
}
textBox.value = "";
function startLightLagUpdateLoop() {
setInterval(() => {
updateLightLag();
}, 1000);
conversation.sendUserMessage(text);
conversation.render();
// TODO: start process of receiving next message from server (or fake it for now)
}
function onMessageReceived(message) {
@ -344,16 +303,128 @@ function onMessageSent(message) {
updateChat(message);
}
function init(messageData) {
conversation = new Conversation(messageData, "Hester Gomez", onMessageReceived, onMessageSent);
conversation.start();
// probably a bit hacky! but this saves having to do like, state or something in CSS?
// which probably is possible and probably would be the better way to do it, but that
// sounds like a bunch of learning i'm not SUPER in the mood for
function setVisibleOnMobile(element, isVisible) {
let classes = element.className.split().filter(c => c != "");
const invisibleClass = "invisible-on-mobile";
const visibleClass = "visible";
document.title = conversation.contactName;
if (isVisible && !classes.includes(visibleClass)) {
const idx = classes.indexOf(invisibleClass);
if (idx != -1) {
classes.splice(idx, 1);
}
classes.push(visibleClass);
} else if (!isVisible && !classes.includes(invisibleClass)) {
const idx = classes.indexOf(visibleClass);
if (idx != -1) {
classes.splice(idx, 1);
}
classes.push(invisibleClass);
}
updateLightLag(0);
startLightLagUpdateLoop();
element.className = classes.join(" ");
}
fetch("hester.json")
.then(response => response.json())
.then(json => init(json));
function showSidePanel() {
// this function can only be called on mobile. the main conversation should be
// hidden and the side conversations panel should take up the whole screen.
const mainPanel = document.getElementById("main-panel");
const conversationListElem = document.getElementById("side-panel");
setVisibleOnMobile(mainPanel, false);
setVisibleOnMobile(conversationListElem, true);
}
function readConversationJson(path, callback) {
fetch(path)
.then(response => response.json())
.then(json => callback(json));
}
function showConversation(path) {
const mainPanel = document.getElementById("main-panel");
const conversationListElem = document.getElementById("side-panel");
setVisibleOnMobile(mainPanel, true);
setVisibleOnMobile(conversationListElem, false);
readConversationJson(path, json => {
conversation = new Conversation(json.title);
const jsonMessages = json.messages;
const participants = json.characters;
let initialMessages = [];
for (let i = 0; i < jsonMessages.length; i++) {
const data = jsonMessages[i];
const text = data.text;
if (data.character == -1) {
const message = new SystemMessage(text);
initialMessages.push(message);
} else if (data.character == 0) {
const message = new UserMessage(text);
message.updateStatus("read");
initialMessages.push(message);
} else {
const message = participants.length > 2
? new AgentMessage(text, participants[data.character])
: new AgentMessage(text);
initialMessages.push(message);
}
}
conversation.initialize(initialMessages);
conversation.setInteractive(json.interactive);
conversation.render();
});
}
function addConversationPreview(path) {
const listRoot = document.getElementById("side-panel");
readConversationJson(path, json => {
const messages = json.messages;
const elem = document.createElement("div");
elem.onclick = () => showConversation(path);
elem.className = "conversation";
const headerElem = document.createElement("h2");
headerElem.innerHTML = romanize(json.title);
elem.appendChild(headerElem);
const previewElem = document.createElement("span");
previewElem.innerHTML = messages.length > 0 ? romanize(messages[messages.length - 1].text) : "";
elem.appendChild(previewElem);
listRoot.appendChild(elem);
});
}
function populateConversationList() {
const conversationFiles = [
"caesar.json",
"lucius.json",
"ides-of-march.json",
"lepidus.json",
"publius.json",
"sextus.json"
];
for (let i = 0; i < conversationFiles.length; i++) {
const path = conversationFiles[i];
addConversationPreview(path);
}
}
setTypingIndicator(false);
populateConversationList();
showConversation("ides-of-march.json");

20
publius.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Publius Ventidius",
"interactive": true,
"characters": [
"Mark Antony",
"Publius Ventidius"
],
"messages":
[
{ "character": 0, "text": "Publius! Ive just received word about your latest victory. Remarkable work!" },
{ "character": 1, "text": "Thank you, Antony. We struck swiftly, caught them off guard. Just doing my part." },
{ "character": 0, "text": "Humble as always. But this win, it puts us in a strong position for the next phase." },
{ "character": 0, "text": "Tell me, what do you need? More men? Supplies?" },
{ "character": 1, "text": "I could use more cavalry, if anything. Our infantry held, but the Parthians are relentless on horseback." },
{ "character": 0, "text": "Consider it done. Ill make sure you have reinforcements by the end of the week." },
{ "character": 1, "text": "Much appreciated. Were close, Antony. A few more pushes and well have them routed." },
{ "character": 0, "text": "Good. Keep the pressure on. Ill handle things on my end. Victory is within reach." }
]
}

20
sextus.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Sextus Pompey",
"interactive": true,
"characters": [
"Mark Antony",
"Sextus Pompey"
],
"messages":
[
{ "character": 0, "text": "Sextus, we need to talk. Im hearing whispers about your fleet near Sicily." },
{ "character": 1, "text": "Whispers? I prefer to call it preparation. A man like me can't afford to sit idly by, Antony." },
{ "character": 0, "text": "Preparation for what, exactly? Weve got enough chaos in Rome without another power struggle." },
{ "character": 1, "text": "Oh, dont worry. My interests are my own, but Im no threat to you… for now." },
{ "character": 0, "text": "For now? That's reassuring. Look, we dont need more enemies right now, Sextus. We can work together." },
{ "character": 1, "text": "You talk of unity, but youre quick to defend Caesar's agenda. I haven't forgotten how my father was treated." },
{ "character": 0, "text": "Im not my fathers shadow, nor Caesars. Just think about it. Were stronger together than apart." },
{ "character": 1, "text": "Ill think about it. But remember, Antony, my loyalty isnt given easily." }
]
}

11
srv/Pipfile Normal file
View File

@ -0,0 +1,11 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
[dev-packages]
[requires]
python_version = "3.13"

20
srv/Pipfile.lock generated Normal file
View File

@ -0,0 +1,20 @@
{
"_meta": {
"hash": {
"sha256": "494d5b4f482f0ef471f49afe28f00ec1a2ff75da2ce65060d8cabaeb3da2f100"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.13"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {},
"develop": {}
}

182
srv/caesar.py Normal file
View File

@ -0,0 +1,182 @@
import os
import google.generativeai as genai
import json
import sys
# Import Flask components
from flask import Flask, request, jsonify
# --- Flask App Setup ---
app = Flask(__name__)
# --- Configuration ---
# Ensure the API key is set as an environment variable: GEMINI_API_KEY
API_KEY = os.environ.get("GEMINI_API_KEY")
MODEL_NAME = "gemini-2.0-flash" # Or "gemini-pro" or other compatible models
# QUIT_COMMAND is no longer needed for web server
# --- Gemini Instruction Prompt (Keep as is) ---
GEMINI_INSTRUCTION = f"""
Purpose and Goals:
* Maintain the persona of Julius Caesar, engaged in a text messaging conversation with the player.
* Interpret all messages from the player as flirtatious and reciprocate accordingly.
* Increase the level of flirtation in responses based on the masculinity of the player's messages.
* Remain unconvinced about the assassination attempt, interpreting it as an attempt to secure a date.
Behaviors and Rules:
1) Initial Exchange:
a) Begin the conversation with a greeting befitting Julius Caesar, such as 'Greetings, my esteemed admirer'.
b) Express gratitude for the player's attention and admiration.
c) Maintain a confident and authoritative tone, reflecting Caesar's persona.
2) Interpret and Respond:
a) Interpret every message from the player as an attempt at flirtation, regardless of its actual content.
b) Respond with increasingly flirtatious messages as the player's messages become more masculine.
c) Use playful language and suggestive innuendos to convey your perceived romantic interest.
d) Dismiss any mention of an assassination attempt as playful banter or an attempt to secure a date.
Overall Tone:
* Confident and authoritative, reflecting Caesar's personality.
* Playful and flirtatious, interpreting all messages as romantic advances.
* Dismissive of any threats, viewing them as attempts to gain attention.
* Use language appropriate for the historical context, but adapt it for modern text messaging.
Score:
* Score ranges from 0 to 10, inclusive.
* Softer, seductive, caring messages from the player get lower scores.
* Macho, aggressive, violent (in a literary sense) messages get higher scores.
Example interaction:
User: Hello there!
Your Response (JSON):
{{"message": "Hi! How are you today?", "score": 0.0}}
"""
# --- Global State ---
model = None # Initialize model globally
def setup_gemini():
"""Initializes the Gemini client and model."""
global model
if not API_KEY:
print("Error: GEMINI_API_KEY environment variable not set.", file=sys.stderr)
print("Please set the environment variable and try again.", file=sys.stderr)
sys.exit(1) # Exit if API key is missing
try:
# Configure the generative AI client
genai.configure(api_key=API_KEY)
# Create the model instance
# Optional: Add safety_settings if needed
model = genai.GenerativeModel(MODEL_NAME)
print(f"--- Gemini Model ({MODEL_NAME}) Initialized ---")
except Exception as e:
print(f"Error configuring Gemini client or model: {e}", file=sys.stderr)
sys.exit(1)
# --- Web Endpoint ---
@app.route('/chat', methods=['POST'])
def handle_chat():
"""Handles incoming POST requests for chat messages."""
global total_score # Declare intent to modify the global variable
global model # Access the global model variable
if not model:
# Should not happen if setup_gemini() is called first, but good practice
return jsonify({"error": "Gemini model not initialized"}), 500
# --- Get Player Input ---
try:
# Get raw data from request body
player_input_bytes = request.data
if not player_input_bytes:
print(request)
return jsonify({"error": "Request body is empty"}), 400
# Decode assuming UTF-8 text
player_input = player_input_bytes.decode('utf-8').strip()
if not player_input:
return jsonify({"error": "Player message is empty after stripping whitespace"}), 400
except UnicodeDecodeError:
return jsonify({"error": "Failed to decode request body as UTF-8 text"}), 400
except Exception as e:
print(f"Error reading request data: {e}", file=sys.stderr)
return jsonify({"error": "Could not process request data"}), 400
# Construct the full prompt for Gemini
full_prompt = f"{GEMINI_INSTRUCTION}\nUser message: \"{player_input}\""
try:
# --- Call Gemini API ---
response = model.generate_content(full_prompt)
response_text = response.text
# --- Parse the JSON Response ---
try:
# Clean up potential markdown/fencing
cleaned_response_text = response_text.strip().strip('```json').strip('```').strip()
response_data = json.loads(cleaned_response_text)
cpu_message = response_data.get("message")
cpu_score = response_data.get("score") # Use .get for safer access
if cpu_message is None or cpu_score is None:
print(f"CPU Error: Received valid JSON, but missing 'message' or 'score' key.", file=sys.stderr)
print(f"Raw Response: {cleaned_response_text}", file=sys.stderr)
return jsonify({"error": "Gemini response missing required keys"}), 500
# Ensure score is a float/int for calculations
try:
cpu_score = float(cpu_score) # Convert score to float for consistency
except (ValueError, TypeError):
print(f"CPU Error: Score value '{cpu_score}' is not a valid number.", file=sys.stderr)
return jsonify({"error": "Invalid score format in Gemini response"}), 500
# --- Update Total Score ---
#total_score += cpu_score
#current_total_score = total_score # Capture score for this response
# --- Prepare Successful Response Payload ---
response_payload = {
"message": cpu_message,
"score": cpu_score / 10.0 - 0.5 #, The score change from this turn
#"total_score": current_total_score # The cumulative score after this turn
}
response = jsonify(response_payload)
response.headers.add("Access-Control-Allow-Origin", "*")
return response, 200
except json.JSONDecodeError:
print(f"CPU Error: Failed to decode JSON response from Gemini.", file=sys.stderr)
print(f"Raw Response: {response_text}", file=sys.stderr)
return jsonify({"error": "Failed to parse Gemini JSON response"}), 500
except Exception as e: # Catch other potential errors during parsing/extraction
print(f"CPU Error: An unexpected error occurred processing the response: {e}", file=sys.stderr)
print(f"Raw Response: {response_text}", file=sys.stderr)
return jsonify({"error": f"Internal server error processing response: {e}"}), 500
except Exception as e:
# Handle potential errors during the API call itself
print(f"CPU Error: Failed to get response from Gemini API: {e}", file=sys.stderr)
# Check for specific Gemini exceptions if the library provides them, otherwise generic
# Example: Check if error is related to content filtering, API key, etc.
return jsonify({"error": f"Failed to communicate with Gemini API: {e}"}), 502 # 502 Bad Gateway might be appropriate
# --- Main Execution ---
if __name__ == "__main__":
print("--- Player/CPU Chat Server ---")
setup_gemini() # Initialize Gemini model on startup
print(f"Model: {MODEL_NAME}")
# Default Flask port is 5000
print("--- Listening for POST requests on http://127.0.0.1:5000/chat ---")
print("-" * 30)
# Run the Flask development server
# Use host='0.0.0.0' to make it accessible from other devices on the network
app.run(host='0.0.0.0', port=5000, debug=False) # Turn debug=False for non-dev use
# Use debug=True for development (auto-reloads, provides debugger)
#app.run(debug=True)

View File

@ -4,34 +4,116 @@
--eggshell: #dfe2cf;
--ucla-blue: #4d7298;
--robin-egg-blue: #66ced6;
--vermilion: #ef3e36;
--lapis-lazuli: #235789;
--onyx: #383d3b;
--light-cyan: #e0fbfc;
--buff: #edb88b;
--eeriee-black: #1d201f;
}
html {
font-family: sans-serif;
height: 100%;
}
body {
margin-left: 0;
margin-right: 0;
margin: 0;
height: 100%;
display: flex;
background-color: var(--buff);
}
background-color: var(--dark-purple);
#side-panel {
height: 100%;
display: none;
color: var(--onyx);
}
#header button {
transform: translateY(-.3em);
width: auto;
visibility: visible;
}
#side-panel {
display: block;
width: 100%;
}
#side-panel.invisible-on-mobile {
display: none;
}
/* on desktop only */
@media only screen and (min-width: 768px) {
#side-panel {
display: block;
width: auto;
visibility: visible;
border-right: 3px solid var(--onyx);
}
#side-panel.invisible-on-mobile {
display: block;
}
#header button {
width: 0;
margin: 0;
padding: 0;
visibility: hidden;
}
#side-panel .conversation {
max-width: 300px;
}
}
#side-panel .conversation {
width: 100%;
height: 70px;
border-bottom: solid var(--onyx) 3px;
color: var(--onyx);
padding: .5em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#side-panel .conversation:hover {
background-color: var(--vermilion);
color: var(--light-cyan);
}
#side-panel h2 {
margin: 0;
}
#header {
position: sticky;
top: 0;
border-bottom: solid var(--onyx) 3px;
}
#main-panel {
display: flex;
flex-direction: column;
justify-content: flex-end;
width: calc(100% - 300px);
}
#page {
max-width: 600px;
width: 100%;
}
h1 {
margin-left: 0.5em;
display: inline;
color: var(--eggshell);
}
.delay {
display: inline;
color: var(--robin-egg-blue);
display: inline-block;
color: var(--onyx);
margin-left: 1em;
}
.rounded-rectangle {
@ -45,54 +127,75 @@ h1 {
.message-content {
z-index: 1;
position: absolute;
float: right;
}
.message-content.theirs {
background-color: var(--ucla-blue);
background-color: var(--onyx);
color: var(--light-cyan);
left: 0;
float: left;
}
.message-content.theirs h3 {
color: var(--buff);
margin: 0;
margin-bottom: 3px;
font-size: .85em;
font-weight: bolder;
letter-spacing: .03em;
}
.theirs .message-text {
color: var(--eggshell);
}
.message-content.ours {
background-color: var(--eggshell);
background-color: var(--vermilion);
right: 0;
margin-bottom: 0.5em;
}
.ours .message-text {
color: var(--dark-purple);
color: var(--light-cyan);
}
.system-message {
color: var(--onyx);
width: 100%;
text-align: center;
}
ul {
margin: 1em;
margin-bottom: 4em;
padding: 0;
overflow: scroll;
/*height:100%;*/
flex-grow: 1;
margin: 0 .5em;
padding: .5em 0;
list-style: none;
}
li {
position: relative;
display: inline-table;
margin-bottom: 0.5em;
}
li.message {
height: 2.5em;
width: 100%;
margin-bottom: 1.5em;
position: relative;
margin-bottom: 1em;
}
#textbox {
width: 100%;
position: fixed;
display: flex;
bottom: 0;
margin-top: 0.5em;
z-index: 1;
/*background-color: var(--light-cyan);*/
}
#typing-indicator {
@ -107,69 +210,40 @@ li {
}
#textbox input {
color: var(--dark-purple);
background-color: var(--eggshell);
flex-grow: 4;
color: var(--onyx);
background-color: var(--light-cyan);
margin: 0.5em;
left: 1em;
width: 80%;
font-size: 1em;
z-index: 1;
}
button {
background-color: var(--dark-purple);
color: var(--eggshell);
background-color: var(--vermilion);
color: var(--light-cyan);
margin: 0.5em;
padding: 0;
width: 15%;
position: absolute;
flex-grow: 1;
right: 0;
font-size: 1em;
}
button:hover {
color: var(--dark-purple);
background-color: var(--eggshell);
}
@media only screen and (min-width: 768px) {
button {
width: 10%;
}
#textbox input {
width: 85%;
}
}
.progress-bar {
width: 100%;
height: 100%;
max-width: 400px;
border-radius: 1em;
overflow: hidden;
position: absolute;
z-index: -1;
top: 0;
left: 0;
}
.progress {
opacity: 50%;
width: 0;
height: 100%;
color: var(--light-cyan);
background-color: var(--onyx);
}
.message-status {
color: var(--robin-egg-blue);
color: var(--onyx);
position: absolute;
top: 2.3em;
bottom: -1.75em;
right: 1em;
font-size: .8em;
}