feat(narrative): add GodModeView, WorldBuilderView, cinematic locations

- New Vue views: GodModeView (inject event, modify emotions with
  preloaded current values, kill character with typed-name
  confirmation) and WorldBuilderView (rules editor, locations with
  cinematic atmosphere field, event log viewer)
- Shared sim-nav strip across Story/GodMode/World views
- Location schema extended with optional atmosphere field — a short
  mood phrase that anchors the opening visual of every scene set there
- Translator's _format_world_locations surfaces atmosphere to the LLM
  prompt so the field actually influences generation
- Routes added for /godmode/:simId and /world/:simId

Tests: 39/39 passing (added test_location_atmosphere_surfaces_in_prompt).
Frontend builds clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
anadoris007 2026-04-22 15:01:08 +05:30
parent e7eef9fa28
commit 484689735f
6 changed files with 540 additions and 4 deletions

View File

@ -37,10 +37,18 @@ def _format_world_locations(world: dict) -> str:
locs = list(world.get("locations", {}).values())[:5]
if not locs:
return "(none)"
return "\n ".join(
f"{_escape_braces(l.get('name', ''))}{_escape_braces(l.get('description', ''))}"
for l in locs
)
lines = []
for l in locs:
name = _escape_braces(l.get("name", ""))
desc = _escape_braces(l.get("description", ""))
line = f"{name}{desc}"
# Cinematic schema: if atmosphere is present, surface it to the LLM as
# a mood anchor for any scene set here.
atmosphere = l.get("atmosphere")
if atmosphere:
line += f" [atmosphere: {_escape_braces(atmosphere)}]"
lines.append(line)
return "\n ".join(lines)
def read_actions_for_round(

View File

@ -142,3 +142,33 @@ def test_generate_prose_includes_world_context():
assert "Magic is forbidden" in prompt
assert "A stranger arrived" in prompt
assert "The Tower" in prompt
def test_location_atmosphere_surfaces_in_prompt():
"""Cinematic location schema — atmosphere should reach the LLM."""
actions = [{"agent_name": "Alice", "action_type": "CREATE_POST", "action_args": {}}]
characters = [
{"id": "1", "name": "Alice", "status": "alive",
"emotional_state": {"current": {"anger": 0, "fear": 0, "joy": 0,
"sadness": 0, "trust": 0.5, "surprise": 0}}},
]
world = {
"rules": [],
"locations": {
"tower": {
"id": "tower",
"name": "The Iron Tower",
"description": "dark spire",
"atmosphere": "oppressive silence, dust in shafts of cold light",
}
},
"event_log": [],
}
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.return_value = "prose"
generate_prose(actions, characters, tone="noir", previous_beats=[], world=world)
prompt = mock_llm.call_args[0][0]
assert "atmosphere" in prompt.lower()
assert "oppressive silence" in prompt

View File

@ -6,6 +6,8 @@ import SimulationRunView from '../views/SimulationRunView.vue'
import ReportView from '../views/ReportView.vue'
import InteractionView from '../views/InteractionView.vue'
import StoryTimelineView from '../views/StoryTimelineView.vue'
import GodModeView from '../views/GodModeView.vue'
import WorldBuilderView from '../views/WorldBuilderView.vue'
const routes = [
{
@ -48,6 +50,18 @@ const routes = [
name: 'Story',
component: StoryTimelineView,
props: true
},
{
path: '/godmode/:simulationId',
name: 'GodMode',
component: GodModeView,
props: true
},
{
path: '/world/:simulationId',
name: 'World',
component: WorldBuilderView,
props: true
}
]

View File

@ -0,0 +1,225 @@
<template>
<div class="godmode">
<nav class="sim-nav">
<router-link :to="`/story/${simId}`">Story</router-link>
<router-link :to="`/godmode/${simId}`" class="active">God Mode</router-link>
<router-link :to="`/world/${simId}`">World</router-link>
</nav>
<h1>God Mode</h1>
<p class="subtitle">Author-controlled interventions. Changes take effect on the next translated round.</p>
<section class="card inject">
<h2> Inject World Event</h2>
<p class="hint">A new world event the narrator will weave into the next scene. (Current enforcement: <strong>hard</strong> opening line MUST reference it.)</p>
<textarea v-model="eventDesc" rows="3"
placeholder="A stranger arrives at the market, carrying a sealed letter."></textarea>
<div class="row">
<input v-model.number="eventRound" type="number" min="0"
placeholder="Round (optional — defaults to next round)" />
<button @click="doInject" :disabled="busy || !eventDesc">Inject</button>
</div>
</section>
<section class="card emotion">
<h2>💭 Modify Character Emotions</h2>
<p class="hint">Overwrite any emotion directly. Unchanged emotions keep their current values.</p>
<select v-model="emoCharId" @change="onEmoCharChange">
<option value="">Select character</option>
<option v-for="c in aliveChars" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<div v-if="emoCharId" class="sliders">
<div v-for="emo in emotions" :key="emo" class="slider-row">
<label>{{ emo }}</label>
<input type="range" min="0" max="1" step="0.05" v-model.number="emoValues[emo]" />
<span>{{ emoValues[emo].toFixed(2) }}</span>
</div>
<button @click="doModifyEmotion" :disabled="busy">Apply</button>
</div>
</section>
<section class="card kill">
<h2> Kill Character</h2>
<p class="warning">
Irreversible in v1. Auto-appends a death event to the world log so
the narrator knows they're gone.
</p>
<select v-model="killCharId" @change="killConfirm = ''">
<option value="">Select character</option>
<option v-for="c in aliveChars" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<input v-if="killCharId" v-model="killConfirm"
:placeholder="`Type '${selectedKillName}' to confirm`" />
<button @click="doKill" :disabled="busy || !canKill" class="danger">
Kill {{ selectedKillName }}
</button>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getCharacters, injectEvent, modifyEmotion, killCharacter } from '../api/narrative'
const route = useRoute()
const simId = route.params.simulationId
const emotions = ['anger', 'fear', 'joy', 'sadness', 'trust', 'surprise']
const characters = ref([])
const busy = ref(false)
const error = ref('')
const success = ref('')
const eventDesc = ref('')
const eventRound = ref(null)
const emoCharId = ref('')
const emoValues = ref(Object.fromEntries(emotions.map(e => [e, 0])))
const killCharId = ref('')
const killConfirm = ref('')
const aliveChars = computed(() =>
characters.value.filter(c => (c.status || 'alive') !== 'dead')
)
const selectedKillName = computed(() => {
const c = characters.value.find(c => c.id === killCharId.value)
return c?.name || ''
})
// Typed-name confirmation case-insensitive, whitespace-trimmed
const canKill = computed(() =>
killCharId.value &&
selectedKillName.value &&
killConfirm.value.trim().toLowerCase() === selectedKillName.value.toLowerCase()
)
async function loadCharacters() {
try {
const res = await getCharacters(simId)
characters.value = res.characters || []
} catch (e) { /* non-fatal */ }
}
// When the user selects a character for emotion editing, preload current values
function onEmoCharChange() {
const c = characters.value.find(c => c.id === emoCharId.value)
if (c) {
const current = c.emotional_state?.current || {}
emoValues.value = Object.fromEntries(emotions.map(e => [e, current[e] ?? 0]))
}
}
function flash(msg) {
success.value = msg
setTimeout(() => { success.value = '' }, 2500)
}
async function doInject() {
busy.value = true
error.value = ''
try {
await injectEvent(simId, eventDesc.value, eventRound.value || null)
flash('Event injected — appears in the next translated round.')
eventDesc.value = ''
eventRound.value = null
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
async function doModifyEmotion() {
busy.value = true
error.value = ''
try {
await modifyEmotion(simId, emoCharId.value, emoValues.value)
flash('Emotions updated.')
await loadCharacters()
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
async function doKill() {
busy.value = true
error.value = ''
try {
const name = selectedKillName.value
await killCharacter(simId, killCharId.value)
flash(`${name} has been killed.`)
killCharId.value = ''
killConfirm.value = ''
await loadCharacters()
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
onMounted(loadCharacters)
</script>
<style scoped>
.godmode {
max-width: 840px;
margin: 0 auto;
padding: 2rem 1.5rem 6rem;
}
.sim-nav {
display: flex; gap: 1.25rem;
padding: 0.75rem 0; margin-bottom: 1.5rem;
border-bottom: 1px solid #e5ddc4; font-size: 0.9rem;
}
.sim-nav a { color: #7d6b3f; text-decoration: none; font-weight: 500; }
.sim-nav a.active { color: #c9a45b; border-bottom: 2px solid #c9a45b; padding-bottom: 0.15rem; }
h1 {
font-family: Georgia, serif; color: #2a2416;
margin: 0 0 0.5rem; font-size: 1.8rem;
}
.subtitle {
color: #7d6b3f; font-size: 0.9rem; margin: 0 0 1.5rem;
}
.card {
background: #faf7f0; border: 1px solid #e5ddc4; border-radius: 6px;
padding: 1.25rem 1.5rem; margin-bottom: 1.5rem;
}
.card h2 { margin: 0 0 0.5rem; font-size: 1.1rem; color: #2a2416; }
.hint { color: #7d6b3f; font-size: 0.85rem; margin: 0 0 0.75rem; }
.warning {
color: #8b0000; font-size: 0.85rem; margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem; background: #ffe5e5; border-radius: 4px;
}
textarea, input, select {
width: 100%; padding: 0.5rem 0.65rem; border: 1px solid #d4c893;
border-radius: 4px; font-size: 0.9rem; background: white; margin-bottom: 0.5rem;
font-family: inherit; box-sizing: border-box;
}
.row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; }
button {
padding: 0.5rem 1rem; background: #c9a45b; color: white;
border: none; border-radius: 4px; cursor: pointer; font-weight: 500;
}
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.danger { background: #8b0000; }
button.danger:disabled { background: #8b0000; opacity: 0.3; }
.sliders { margin-top: 0.75rem; }
.slider-row {
display: grid; grid-template-columns: 80px 1fr 50px;
gap: 0.5rem; align-items: center; font-size: 0.85rem;
margin-bottom: 0.35rem;
}
.slider-row label { text-transform: uppercase; letter-spacing: 0.05em; color: #7d6b3f; font-size: 0.75rem; }
.error { background: #ffe5e5; color: #8b0000; padding: 0.85rem 1rem; border-radius: 4px; margin-top: 1rem; }
.success { background: #e5f7e5; color: #2a6b2a; padding: 0.85rem 1rem; border-radius: 4px; margin-top: 1rem; }
</style>

View File

@ -1,5 +1,11 @@
<template>
<div class="story-timeline">
<nav class="sim-nav">
<router-link :to="`/story/${simId}`" active-class="active">Story</router-link>
<router-link :to="`/godmode/${simId}`" active-class="active">God Mode</router-link>
<router-link :to="`/world/${simId}`" active-class="active">World</router-link>
</nav>
<header class="page-header">
<div class="header-left">
<h1>Story Timeline</h1>
@ -134,6 +140,24 @@ onMounted(refresh)
margin: 0 auto;
padding: 2rem 1.5rem 6rem;
}
.sim-nav {
display: flex;
gap: 1.25rem;
padding: 0.75rem 0;
margin-bottom: 1rem;
border-bottom: 1px solid #e5ddc4;
font-size: 0.9rem;
}
.sim-nav a {
color: #7d6b3f;
text-decoration: none;
font-weight: 500;
}
.sim-nav a.active {
color: #c9a45b;
border-bottom: 2px solid #c9a45b;
padding-bottom: 0.15rem;
}
.page-header {
display: flex;
justify-content: space-between;

View File

@ -0,0 +1,235 @@
<template>
<div class="world-builder">
<nav class="sim-nav">
<router-link :to="`/story/${simId}`">Story</router-link>
<router-link :to="`/godmode/${simId}`">God Mode</router-link>
<router-link :to="`/world/${simId}`" class="active">World</router-link>
</nav>
<h1>World Builder</h1>
<p class="subtitle">Ground your story. Rules shape the world; locations shape the scenes.</p>
<section class="card">
<h2>World Rules</h2>
<p class="hint">One rule per line. These appear in every translation prompt as background context.</p>
<textarea v-model="rulesText" rows="6" placeholder="Magic is forbidden
Winter is near
The kingdom is divided"></textarea>
<button @click="saveRules" :disabled="busy">Save Rules</button>
</section>
<section class="card">
<h2>Locations</h2>
<p class="hint">
Each location's <strong>atmosphere</strong> is a short mood phrase it anchors
the opening visual of every scene set there.
</p>
<div v-if="locations.length" class="location-list">
<div v-for="loc in locations" :key="loc.id" class="location-item">
<div class="loc-header">
<strong>{{ loc.name }}</strong>
<span class="loc-id">{{ loc.id }}</span>
</div>
<p v-if="loc.description" class="loc-desc">{{ loc.description }}</p>
<p v-if="loc.atmosphere" class="loc-atmosphere">"{{ loc.atmosphere }}"</p>
</div>
</div>
<p v-else class="muted">No locations yet.</p>
<form @submit.prevent="addLocation" class="location-form">
<div class="form-row">
<input v-model="newLoc.id" placeholder="id (e.g. iron_tower)" required />
<input v-model="newLoc.name" placeholder="Name (The Iron Tower)" required />
</div>
<input v-model="newLoc.description" placeholder="Description (what it looks like)" />
<input v-model="newLoc.atmosphere"
placeholder="Atmosphere — short mood phrase (e.g. oppressive silence, dust in shafts of cold light)" />
<button type="submit" :disabled="busy">Add / Update Location</button>
</form>
</section>
<section class="card">
<h2>Event Log</h2>
<p class="hint">World events, newest first. Populated by God Mode interventions.</p>
<ol v-if="events.length" class="event-log">
<li v-for="e in events" :key="e.id" :class="`evt-${e.type}`">
<span class="event-round">Round {{ e.round }}</span>
<span class="event-type">{{ e.type }}</span>
<span class="event-desc">{{ e.description }}</span>
</li>
</ol>
<p v-else class="muted">No events yet.</p>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getWorld, setWorldRules, upsertLocation } from '../api/narrative'
const route = useRoute()
const simId = route.params.simulationId
const world = ref({ rules: [], locations: {}, event_log: [] })
const rulesText = ref('')
const newLoc = ref({ id: '', name: '', description: '', atmosphere: '' })
const busy = ref(false)
const error = ref('')
const success = ref('')
const locations = computed(() => Object.values(world.value.locations || {}))
// Event log: show newest first for readability
const events = computed(() => (world.value.event_log || []).slice().reverse())
async function load() {
try {
const res = await getWorld(simId)
world.value = res
rulesText.value = (res.rules || []).join('\n')
} catch (e) {
error.value = e?.response?.data?.error || e.message
}
}
function flash(msg) {
success.value = msg
setTimeout(() => { success.value = '' }, 2500)
}
async function saveRules() {
busy.value = true
error.value = ''
try {
const rules = rulesText.value.split('\n').map(r => r.trim()).filter(Boolean)
await setWorldRules(simId, rules)
flash('Rules saved.')
await load()
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
async function addLocation() {
busy.value = true
error.value = ''
try {
// Only send non-empty optional fields so backend stores a clean record
const payload = { id: newLoc.value.id, name: newLoc.value.name }
if (newLoc.value.description) payload.description = newLoc.value.description
if (newLoc.value.atmosphere) payload.atmosphere = newLoc.value.atmosphere
await upsertLocation(simId, payload)
flash(`Location ${newLoc.value.name} saved.`)
newLoc.value = { id: '', name: '', description: '', atmosphere: '' }
await load()
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
onMounted(load)
</script>
<style scoped>
.world-builder {
max-width: 840px;
margin: 0 auto;
padding: 2rem 1.5rem 6rem;
}
.sim-nav {
display: flex; gap: 1.25rem;
padding: 0.75rem 0; margin-bottom: 1.5rem;
border-bottom: 1px solid #e5ddc4; font-size: 0.9rem;
}
.sim-nav a { color: #7d6b3f; text-decoration: none; font-weight: 500; }
.sim-nav a.active { color: #c9a45b; border-bottom: 2px solid #c9a45b; padding-bottom: 0.15rem; }
h1 {
font-family: Georgia, serif; color: #2a2416;
margin: 0 0 0.5rem; font-size: 1.8rem;
}
.subtitle {
color: #7d6b3f; font-size: 0.9rem; margin: 0 0 1.5rem;
}
.card {
background: #faf7f0; border: 1px solid #e5ddc4; border-radius: 6px;
padding: 1.25rem 1.5rem; margin-bottom: 1.5rem;
}
.card h2 { margin: 0 0 0.5rem; font-size: 1.1rem; color: #2a2416; }
.hint, .muted {
color: #7d6b3f; font-size: 0.85rem; margin: 0 0 0.75rem;
}
.muted { font-style: italic; }
textarea, input {
width: 100%; padding: 0.5rem 0.65rem; border: 1px solid #d4c893;
border-radius: 4px; font-size: 0.9rem; background: white; margin-bottom: 0.5rem;
font-family: inherit; box-sizing: border-box;
}
textarea { resize: vertical; font-family: Georgia, serif; }
button {
padding: 0.5rem 1rem; background: #c9a45b; color: white;
border: none; border-radius: 4px; cursor: pointer; font-weight: 500;
}
button:disabled { opacity: 0.5; cursor: wait; }
.location-list {
margin-bottom: 1rem;
border: 1px solid #e5ddc4;
border-radius: 4px;
overflow: hidden;
}
.location-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5ddc4;
background: white;
}
.location-item:last-child { border-bottom: none; }
.loc-header {
display: flex; justify-content: space-between; align-items: baseline;
}
.loc-header strong { color: #2a2416; font-size: 0.95rem; }
.loc-id {
font-family: 'SF Mono', Menlo, monospace; font-size: 0.75rem;
color: #7d6b3f; background: #f5efd9; padding: 0.1rem 0.4rem; border-radius: 3px;
}
.loc-desc { margin: 0.35rem 0 0; color: #5a4f2f; font-size: 0.88rem; }
.loc-atmosphere {
margin: 0.35rem 0 0; color: #8b7a40; font-size: 0.85rem;
font-style: italic; font-family: Georgia, serif;
}
.location-form {
margin-top: 0.75rem; padding-top: 0.75rem;
border-top: 1px dashed #d4c893;
}
.form-row {
display: grid; grid-template-columns: 1fr 2fr; gap: 0.5rem;
}
.event-log { list-style: none; padding: 0; margin: 0; }
.event-log li {
display: grid;
grid-template-columns: 80px 160px 1fr;
gap: 0.75rem;
padding: 0.55rem 0;
border-bottom: 1px dashed #e5ddc4;
font-size: 0.88rem;
align-items: baseline;
}
.event-log li:last-child { border-bottom: none; }
.event-round { color: #c9a45b; font-weight: 600; }
.event-type {
font-family: 'SF Mono', Menlo, monospace;
font-size: 0.72rem; color: #7d6b3f;
text-transform: uppercase; letter-spacing: 0.03em;
}
.evt-god_mode_death .event-type { color: #8b0000; }
.evt-god_mode_injection .event-type { color: #a0571a; }
.error { background: #ffe5e5; color: #8b0000; padding: 0.85rem 1rem; border-radius: 4px; margin-top: 1rem; }
.success { background: #e5f7e5; color: #2a6b2a; padding: 0.85rem 1rem; border-radius: 4px; margin-top: 1rem; }
</style>