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] locs = list(world.get("locations", {}).values())[:5]
if not locs: if not locs:
return "(none)" return "(none)"
return "\n ".join( lines = []
f"{_escape_braces(l.get('name', ''))}{_escape_braces(l.get('description', ''))}" for l in locs:
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( 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 "Magic is forbidden" in prompt
assert "A stranger arrived" in prompt assert "A stranger arrived" in prompt
assert "The Tower" 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 ReportView from '../views/ReportView.vue'
import InteractionView from '../views/InteractionView.vue' import InteractionView from '../views/InteractionView.vue'
import StoryTimelineView from '../views/StoryTimelineView.vue' import StoryTimelineView from '../views/StoryTimelineView.vue'
import GodModeView from '../views/GodModeView.vue'
import WorldBuilderView from '../views/WorldBuilderView.vue'
const routes = [ const routes = [
{ {
@ -48,6 +50,18 @@ const routes = [
name: 'Story', name: 'Story',
component: StoryTimelineView, component: StoryTimelineView,
props: true 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> <template>
<div class="story-timeline"> <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"> <header class="page-header">
<div class="header-left"> <div class="header-left">
<h1>Story Timeline</h1> <h1>Story Timeline</h1>
@ -134,6 +140,24 @@ onMounted(refresh)
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.5rem 6rem; 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 { .page-header {
display: flex; display: flex;
justify-content: space-between; 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>