piclets / src /lib /components /Battle /LLMBattleEngine.svelte
Fraser's picture
BIG CHANGE
c703ea3
<script lang="ts">
import type { PicletInstance } from '$lib/db/schema';
import type { GradioClient } from '$lib/types';
interface BattleUpdate {
battle_updates: string[];
player_pokemon_status: string;
player_pokemon_hp: 'Empty' | 'Very Low' | 'Low' | 'Medium' | 'High' | 'Very High' | 'Full';
enemy_pokemon_status: string;
enemy_pokemon_hp: 'Empty' | 'Very Low' | 'Low' | 'Medium' | 'High' | 'Very High' | 'Full';
next_to_act: 'player' | 'enemy';
available_actions: string[];
}
interface Props {
playerPiclet: PicletInstance;
enemyPiclet: PicletInstance;
commandClient: GradioClient;
onBattleEnd: (winner: 'player' | 'enemy') => void;
rosterPiclets?: PicletInstance[]; // Optional roster for switching
}
let { playerPiclet, enemyPiclet, commandClient, onBattleEnd, rosterPiclets }: Props = $props();
// Battle state
let battleState: BattleUpdate = $state({
battle_updates: [],
player_pokemon_status: 'Ready for battle',
player_pokemon_hp: 'Full',
enemy_pokemon_status: 'Ready for battle',
enemy_pokemon_hp: 'Full',
next_to_act: 'player',
available_actions: []
});
let battleHistory: string[] = $state([]);
let isProcessing: boolean = $state(false);
let currentPlayerPiclet: PicletInstance = $state(playerPiclet);
let showPicletSelector: boolean = $state(false);
// Dice rolling system
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1; // D20 roll
}
function getActionEffectiveness(roll: number): { success: string; description: string } {
if (roll === 20) return { success: 'Critical Success', description: 'The action succeeds spectacularly!' };
if (roll >= 15) return { success: 'Success', description: 'The action succeeds well!' };
if (roll >= 10) return { success: 'Partial Success', description: 'The action has some effect.' };
if (roll >= 5) return { success: 'Failure', description: 'The action fails but something minor happens.' };
if (roll === 1) return { success: 'Critical Failure', description: 'The action backfires!' };
return { success: 'Failure', description: 'The action fails.' };
}
// Generate text using Command client
async function generateBattleUpdate(prompt: string): Promise<BattleUpdate> {
const message = {
text: prompt,
files: []
};
const result = await commandClient.predict("/chat", {
message: message,
max_new_tokens: 1000
});
const responseText = result.data || '';
console.log('LLM Response:', responseText);
// Extract JSON from response
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error('No JSON found in response');
const battleUpdate: BattleUpdate = JSON.parse(jsonMatch[0]);
return battleUpdate;
} catch (error) {
console.error('Failed to parse battle response:', error);
// Fallback response
return {
battle_updates: ['The battle continues...'],
player_pokemon_status: battleState.player_pokemon_status,
player_pokemon_hp: battleState.player_pokemon_hp,
enemy_pokemon_status: battleState.enemy_pokemon_status,
enemy_pokemon_hp: battleState.enemy_pokemon_hp,
next_to_act: battleState.next_to_act === 'player' ? 'enemy' : 'player',
available_actions: ['Attack', 'Defend', 'Special Move']
};
}
}
// Initialize battle
async function startBattle() {
isProcessing = true;
const initialPrompt = `Let's role play a Pokemon game using my custom Pokemon, the player will use ${currentPlayerPiclet.typeId} and the enemy will use ${enemyPiclet.typeId}.
You will return a brief description on what happens because of the action.
I will send you an update of the enemy or players move and the success of the action (in a DnD style). Be sure to be as creative and engaging as possible when defining battle updates and available actions.
Player Pokemon: ${currentPlayerPiclet.typeId}
${currentPlayerPiclet.description}
Enemy Pokemon: ${enemyPiclet.typeId}
${enemyPiclet.description}
Each response should be a json object with fields:
\`\`\`json
{
"battle_updates": [list with 1 sentence per entry describing what just happened in battle],
"player_pokemon_status": "1 sentence description of how the Pokemon is doing",
"player_pokemon_hp": "enum Empty, Very Low, Low, Medium, High, Very High, Full",
"enemy_pokemon_status": "1 sentence description of how the Pokemon is doing",
"enemy_pokemon_hp": "enum Empty, Very Low, Low, Medium, High, Very High, Full",
"next_to_act": "enum player/enemy",
"available_actions": ["short list of 1 sentence actions of what to have the next_to_act Pokemon do next"]
}
\`\`\`
Start with just some intro updates describing both monsters being on the battlefield.`;
try {
const update = await generateBattleUpdate(initialPrompt);
battleState = update;
battleHistory.push(initialPrompt);
} catch (error) {
console.error('Failed to start battle:', error);
}
isProcessing = false;
}
// Execute player action
async function executeAction(actionDescription: string) {
if (isProcessing) return;
isProcessing = true;
const roll = rollDice();
const effectiveness = getActionEffectiveness(roll);
const prompt = `Player chooses: "${actionDescription}"
Dice roll: ${roll}/20 (${effectiveness.success})
Effect: ${effectiveness.description}
Update the battle state based on this action and its effectiveness. Then have the enemy take their turn if appropriate.`;
try {
const update = await generateBattleUpdate(prompt);
battleState = update;
battleHistory.push(prompt);
// Check for battle end conditions
if (battleState.player_pokemon_hp === 'Empty') {
onBattleEnd('enemy');
} else if (battleState.enemy_pokemon_hp === 'Empty') {
onBattleEnd('player');
}
} catch (error) {
console.error('Failed to execute action:', error);
}
isProcessing = false;
}
// Switch Piclet function
async function switchPiclet(newPiclet: PicletInstance) {
if (isProcessing) return;
isProcessing = true;
showPicletSelector = false;
const switchPrompt = `Player switches from ${currentPlayerPiclet.typeId} to ${newPiclet.typeId}!
New Pokemon: ${newPiclet.typeId}
${newPiclet.description}
Update the battle to show the switch and have the enemy react accordingly.`;
try {
currentPlayerPiclet = newPiclet;
const update = await generateBattleUpdate(switchPrompt);
battleState = update;
battleHistory.push(switchPrompt);
} catch (error) {
console.error('Failed to switch Piclet:', error);
}
isProcessing = false;
}
// Auto-start battle when component mounts
$effect(() => {
startBattle();
});
// Export functions for parent component
export { executeAction, switchPiclet };
</script>
<div class="llm-battle-engine">
<!-- Battle Narrative Display -->
<div class="battle-narrative">
<h3>Battle Progress</h3>
{#each battleState.battle_updates as update}
<div class="battle-update">{update}</div>
{/each}
</div>
<!-- Pokemon Status -->
<div class="pokemon-status">
<div class="player-status">
<h4>{currentPlayerPiclet.typeId}</h4>
<div class="hp-indicator hp-{battleState.player_pokemon_hp.toLowerCase().replace(' ', '-')}">{battleState.player_pokemon_hp}</div>
<p>{battleState.player_pokemon_status}</p>
</div>
<div class="enemy-status">
<h4>{enemyPiclet.typeId}</h4>
<div class="hp-indicator hp-{battleState.enemy_pokemon_hp.toLowerCase().replace(' ', '-')}">{battleState.enemy_pokemon_hp}</div>
<p>{battleState.enemy_pokemon_status}</p>
</div>
</div>
<!-- Available Actions (when it's player's turn) -->
{#if battleState.next_to_act === 'player' && !isProcessing}
<div class="available-actions">
<h4>Choose Your Action:</h4>
<!-- Battle Actions -->
{#each battleState.available_actions as action}
<button
class="action-button"
onclick={() => executeAction(action)}
>
{action}
</button>
{/each}
<!-- Piclet Switching -->
{#if rosterPiclets && rosterPiclets.length > 1}
<button
class="switch-button"
onclick={() => showPicletSelector = !showPicletSelector}
>
🔄 Switch Piclet
</button>
{/if}
</div>
<!-- Piclet Selector -->
{#if showPicletSelector && rosterPiclets}
<div class="piclet-selector">
<h4>Choose Piclet:</h4>
<div class="piclet-grid">
{#each rosterPiclets as piclet}
{#if piclet.id !== currentPlayerPiclet.id}
<button
class="piclet-option"
onclick={() => switchPiclet(piclet)}
>
<img src={piclet.imageUrl} alt={piclet.typeId} />
<span>{piclet.typeId}</span>
<span class="tier tier-{piclet.tier}">{piclet.tier}</span>
</button>
{/if}
{/each}
</div>
</div>
{/if}
{:else if isProcessing}
<div class="processing">
<div class="spinner"></div>
<p>Processing battle turn...</p>
</div>
{:else}
<div class="enemy-turn">
<p>Enemy is deciding their move...</p>
</div>
{/if}
</div>
<style>
.llm-battle-engine {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.battle-narrative {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
max-height: 200px;
overflow-y: auto;
}
.battle-update {
margin-bottom: 0.5rem;
padding: 0.5rem;
background: white;
border-radius: 4px;
border-left: 3px solid #007bff;
}
.pokemon-status {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.player-status, .enemy-status {
padding: 1rem;
border-radius: 8px;
text-align: center;
}
.player-status {
background: rgba(0, 123, 255, 0.1);
border: 2px solid #007bff;
}
.enemy-status {
background: rgba(220, 53, 69, 0.1);
border: 2px solid #dc3545;
}
.hp-indicator {
font-weight: bold;
padding: 0.25rem 0.5rem;
border-radius: 16px;
margin: 0.5rem 0;
display: inline-block;
}
.hp-full { background: #28a745; color: white; }
.hp-very-high { background: #40c757; color: white; }
.hp-high { background: #6bc267; color: white; }
.hp-medium { background: #ffc107; color: black; }
.hp-low { background: #fd7e14; color: white; }
.hp-very-low { background: #dc3545; color: white; }
.hp-empty { background: #6c757d; color: white; }
.available-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.action-button {
padding: 0.75rem 1rem;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.action-button:hover {
background: #0056b3;
}
.switch-button {
padding: 0.75rem 1rem;
background: #28a745;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
margin-top: 0.5rem;
}
.switch-button:hover {
background: #1e7e34;
}
.piclet-selector {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.piclet-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.5rem;
margin-top: 0.5rem;
}
.piclet-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
background: white;
border: 2px solid #dee2e6;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.piclet-option:hover {
border-color: #007bff;
background: #f0f7ff;
}
.piclet-option img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
.piclet-option span {
font-size: 0.8rem;
text-align: center;
}
.tier {
padding: 0.1rem 0.3rem;
border-radius: 8px;
font-size: 0.7rem;
font-weight: bold;
text-transform: uppercase;
}
.tier-low { background: #6c757d; color: white; }
.tier-medium { background: #28a745; color: white; }
.tier-high { background: #fd7e14; color: white; }
.tier-legendary { background: #dc3545; color: white; }
.processing, .enemy-turn {
text-align: center;
padding: 2rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>