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:
parent
e7eef9fa28
commit
484689735f
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue