Skip to content

feat: World Mode — worldbuilding-first GM operating system - #57

Open
FumingPower3925 wants to merge 30 commits into
mainfrom
world-mode
Open

feat: World Mode — worldbuilding-first GM operating system#57
FumingPower3925 wants to merge 30 commits into
mainfrom
world-mode

Conversation

@FumingPower3925

Copy link
Copy Markdown
Owner

What

A new /world mode turning the app into a GM operating system for a worldbuilding-first sandbox campaign. Files stay the database (mundo/ markdown + YAML frontmatter); the app is viewer + cockpit + recorder; the between-sessions AI agent is the world engine.

World viewer

  • mundo/ auto-detection + scanner: entities (sistemas/lugares/facciones/pnjs/pistas/tramas/eventos), playable places reuse scanSessionFolder() verbatim, full validation with degraded load (never throws), Diagnóstico panel + "Copiar informe"
  • Hand-rolled SVG semantic-zoom starmap (no new render deps): sector → system orbital → site-list tiers, knowledge-state encoding (desconocido/rumoreado/conocido/visitado), faction rings, leads badges, party marker with rollup, ?e= deep links, Ctrl+K accent-folded search

Play cockpit

  • Party status bar (in-world calendar, credits, manifest-driven gauges)
  • Quick-log bar: every action ≤2 taps (audited), sonner toasts
  • Travel: route planner (euclidean legs, portal handling), consumption preview + GM override, per-day stepper with travel events
  • Event engine: condition grammar (evalCondition), weighted draws with sesgos, :::efecto one-tap journal buttons
  • LeadsBoard: derived-never-stored accionable, trama grouping, plazo chips
  • ActRunner: plays a place's ::: acts fullscreen reusing ActPanels/Audio/Timer/Initiative unmodified

Session recorder

  • Journal write path (FS Access readwrite, scoped to diario/ + estado/): strict parseable line format, serialized coalescing rewrites, replay-based undo, crash mirror + recovery, sesion_activa lock, byte-preserved estado body

Verification

  • 381 unit tests / 1073 expects · 74 e2e ×2 runs zero flakes · tsc clean · static export
  • Classic session mode byte-identical (only the plan-mandated helper extraction; verified per-function)
  • OPFS e2e seam exercises the real write path; console-hygiene test locks zero errors/warnings
  • Adversarially verified per milestone (independent verify agents fixed 9 real bugs along the way)
  • Real campaign mundo/ folder validated end-to-end: 0 errores, 0 avisos

Built per the approved M0–M6 plan; M6 (campaign-side mundo/ skeleton) lives in the campaign repo.

…, handle persistence

- add zustand, yaml, idb-keyval, sonner
- scope bun test to lib/ (e2e specs were bleeding into bun test as failures)
- extract shared fsScanUtils from sessionScanner (behavior unchanged, tests green)
- FileSystemManager: writeTextFile (atomic via createWritable), exists,
  query/requestWritePermission; selectFolder(mode)
- FileSystemHandle permission methods added to FS Access API type defs
- lib/dirHandle.ts: IndexedDB-persisted campaign handle (one-click reconnect)
- shared lib/testUtils/mockFs.ts with write support + fileSystem unit tests
- types/world.ts: full world-mode type model (entities, leads, tramas, manifest, validation)
- lib/world/frontmatter.ts: browser-safe YAML frontmatter (never throws; es/en alias normalize)
- lib/world/worldScanner.ts: mundo/ scanner — entities, playable places via scanSessionFolder
  reuse with path prefixing, childrenOf, region inheritance, accionable derivation,
  full validation (degraded load, never throws), batched reads
- lib/world/stores.ts: zustand worldStore + uiStore
- components/world/: StarMap (hand-rolled SVG semantic zoom), SectorView, EntityNode
  (knowledge encoding + faction ring + leads badge), PartyMarker, EntityPanel, DiagnosticsPanel
- app/world/: viewer page (dirHandle reconnect flows, progress, Diagnóstico + Copiar informe,
  OPFS test hook) + setup-page 'Mundo detectado' card
- e2e: mundo-campaign fixture + OPFS materializer seam + 6 world.spec.ts tests
- verified: tsc clean, 95 unit / 311 expects, static build, 57 e2e green
…deep-links

- lib/world/partyState.ts: estado/grupo.md parser (byte-preserving body) + formatFecha calendar
- lib/world/worldNav.ts: tier-placement helpers (ancestryChain, sectorNodeFor, tierTargetFor)
- components/world/SystemView.tsx: orbital tier (FNV-1a deterministic angles, portal glyph)
- components/world/SiteList.tsx: non-spatial tier 3 with servicios icons + lead stars
- components/world/PartyStatusBar.tsx: read-only cockpit band (breadcrumb, fecha, créditos, gauges)
- WorldSearchDialog (Ctrl+K, accent-folded lunr) + ?e= deep links
- party marker rollup across tiers; EntityPanel 'Pistas en el interior'; portal glyph at sector
- verified: tsc clean, 160 unit / 463 expects, static build, 64 e2e green
…ckpit

- lib/world/logEntries.ts: strict journal line format, per-type factories with
  injectable clock, pure snapshot reducer, byte-stable round-trip (CR/LF sanitized)
- lib/world/journalWriter.ts: serialized coalescing write queue, denied/retry,
  write-surface enforcement (only mundo/diario/ + mundo/estado/)
- lib/world/stores.ts: partyStore — single log() mutation pipeline, replay-based
  undoLast, session lifecycle with sesion_activa lock, snapshot-carrying crash
  mirror, debounced estado writes + pagehide flush; stranded-fin dedup on close
- estado serializer: frontmatter rewrite with byte-preserved body
- scanner: diario/*.md parsing + unprocessed-journal knowledge overlay;
  applyLlegadaConocimiento shared by live moves and scan overlay
- cockpit: QuickLogBar (8 actions, ≤2 taps), MoverDialog, JournalPanel with undo,
  PartyStatusBar upgraded (manifest-driven gauges, session controls, write-status),
  SessionRecoveryBanner, stale-lock notice, sonner toasts
- e2e: full recorder loop against OPFS (start→log→undo→end, body byte-compare)
- verified: tsc clean, 268 unit / 66 e2e green; adversarial write-path audit passed
- lib/world/conditions.ts: condition grammar evalCondition/evalConditions +
  buildCondContext (region/etiquetas/facciones inheritance; null = unparseable)
- lib/world/eventEngine.ts: parseEventAttrs (first-= rule), parseEventTable
  (## events with {peso;si;etiquetas}, :::efecto extraction), applicableTables,
  weighted drawEvent with sesgos + injectable RNG
- lib/world/travel.ts: computeTravelPlan (3-leg routes, ceil euclidean × días,
  portal chains = no route, consumption schedule, insufficiency warnings)
- scanner: eventos/*.md parsing; accionable upgraded to real condition eval;
  whitespace-medidor aviso
- UI: TravelDialog (consumption preview + GM override), TravelStepper (per-day
  loop: event draw / continue / resolve rest), RoutePreview, EventDrawer
  (efecto one-tap journal buttons + outcomes), LeadsBoard (trama grouping,
  accionable star semantics unified, plazo chips)
- page: travel mode with per-day consumption entries, stay events via Evento
  button, live accionable re-derivation, undo guarded during travel
- verified: tsc clean, 376 unit / 1064 expects, static build, 70 e2e green;
  adversarial pass: hand-recomputed routes, 49-string condition fuzz, 10k-draw
  distribution check, replay equivalence, clamp rails
- ActRunner: fullscreen overlay playing a place's acts inside /world —
  reuses ActPanels/MarkdownViewer/AudioControls/PartTimer/InitiativeTracker/
  ImageViewer unmodified; per-runner AudioManager with fade-out on close;
  act start/end journaled as notas; Jugar from EntityPanel + SiteList
- uiStore persistence (world.ui.v1): tier/selection/panel/viewport survive
  reload, field-validated restore; per-tier viewport memory
- medidor efecto hardening: unknown gauge degrades to nota + warning toast
- tap-target sweep to 44px across the whole cockpit; aria-labels on all
  icon-only buttons; e2e console-hygiene lock-in (zero errors/warnings)
- fixed the long-standing general.spec focus flake properly (bringToFront)
- verifier fixes: ActRunner image object-URL leak; acto-iniciado nota uses
  first VISIBLE part
- verified: tsc clean, 381 unit / 1073 expects, static build, 74 e2e ×2 runs
  zero flakes; full plan checklist audited (classic mode byte-identical)
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 3, 2026

Copy link
Copy Markdown

Deploying ttrpg-session-manager with  Cloudflare Pages  Cloudflare Pages

Latest commit: b2708d1
Status: ✅  Deploy successful!
Preview URL: https://7dbd17d9.starfinder2e-session-manager.pages.dev
Branch Preview URL: https://world-mode.starfinder2e-session-manager.pages.dev

View logs

The fit effect could run while the wrapper measured 0×0/a few px (hydration
timing, small embeds) — it bailed and nothing re-triggered it, leaving the
map at the identity transform with every node off-screen. A ResizeObserver
now bumps a rev the effect depends on, and unusable sizes (<50px) return
without marking fittedRef so the retry recomputes. Found live-testing the
real campaign mundo/ in an embedded preview browser.

Also: gitignore the machine-local preview seed; .claude/launch.json for dev.
Economy (audited: fuel barely depleted — 20-day trek cost 1; food never
depleted at rest and had no UI):
- combustible: ceil(sector-leg días / combustible_cada_dias) [new knob,
  default 4]; ticks at cadence boundaries + remainder on leg's last day;
  intra-system free; 20-day Nodo Central run = exactly a full tank (5/5)
- víveres: calendar-anchored — dia_mundo crossing multiples of
  viveres_cada_dias ticks -1, identically for travel and descanso; no
  hidden remainder state; every tick journals a medidor line (replay-exact)
- new 'Descansar' quick-log control (+1/+3/custom días), session-gated
- TravelDialog: per-gauge depletion-day preview ('Se agota el día X de Y')
- empty gauge → deficit nota + 'Tirar evento' toast action (complication hook)
- legacy combustible_por_tramo: never numeric-mapped; deprecation aviso

Functional/UX audit fixes: estado/grupo.md bootstrap CTA (dead-end fixed),
Diagnóstico as overlay, duplicate panel suppression at tier 3, undo actions
on toasts, humanized journal rows, undo-disabled tooltips, travel-gated
buttons with truthful titles, stepper cancel confirm, search/mover dialog
edge cases, dead code removal, +6 e2e (incl. tier-3 SiteList coverage)

Campaign side: mundo.md migrated to combustible_cada_dias; PROTOCOLO.md
synced to real journal grammar + 'Consumo automático' don't-double-charge
rule; ejemplo_diario.md rewritten arithmetically consistent

verified: tsc clean, 400 unit / 1126 expects, build, 80 e2e ×2; independent
verifier hand-recomputed all benchmark traces + replay equivalence
…-pnj orphan rule

- splitFlatPlanParts: the legacy single-part fallback bundled a place's acts
  into 'Part 1' with the rest demoted to support docs — the ActRunner needs
  each act as its own selectable part (names from filenames; support content
  attaches to the first part, matching the path-folder convention). World-only
  transform; classic session mode untouched.
- orphan aviso skips pnjs anchored via a resolving ubicacion (forward-seeded
  NPCs at not-yet-visited places are not Diagnóstico noise)
- verified against the full Sector Meridiano build: 4 playable places expose
  4/4/4/3 parts, 0 errores / 0 avisos; 402 unit + 80 e2e green
- MoverDialog + WorldSearchDialog: shadcn ScrollArea root has no
  overflow-hidden, so long lists visually bled through the dialog card
  (surfaced with the full 16-destination sector) — swapped to native
  max-h + overflow-y-auto scroll containers
- new 'Recargar mundo' header button: the app never watches the filesystem,
  so after agent maintenance (or an app update changing the scanner) the GM
  rescans in place instead of hard-reloading; disabled mid-session. An
  ActRunner error message already referenced this button — now it exists.
- gates: tsc clean, 402 unit, static build, 80 e2e green
… ActRunner handoff

- scanner: optional mundo/musica/ (root = generic BGM rotation, subfolders =
  named event playlists); absent folder = empty, no aviso
- WorldAudioDock: bottom-left collapsible dock hosting the existing
  AudioControls unmodified (scoped-CSS adapter documented); hidden when no tracks
- page-owned AudioManager with fade disposal on rescan/unmount; duck/restore
  handoff around the ActRunner (resume only if it was playing; race-guarded)
- campaign: musica/_instrucciones.md + 3 new generic prompts (2 ambient beds,
  1 rising-tension loop) wired into _PROMPTS_AUDIOVISUAL
- gates: tsc clean, 406 unit, build, 83 e2e green; adversarial audio review
…, pista detail

- scanner: mundo/imagenes/<id>.<ext> profile-image convention attached to every
  entity (WorldEntityBase.imagen); basename-with-no-entity -> aviso
- EntityPanel: profile thumbnail banner + 'Personajes' section (pnjs whose
  ubicacion is the place); PnjCard dossier view (portrait, chips, estado, body)
- FullscreenImage: player-safe viewer — solid black, image and NOTHING else,
  no title/filename/chrome; click or Escape to close. ActRunner switched to it
  (play's ImageViewer leaked the filename onto the projected screen)
- PLAYER-SAFETY FIX: sonner toasts render at z-index 999999999, above the
  z-[200] overlay — a background write firing a toast leaked onto the table.
  FullscreenImage now flags html[data-projecting]; globals.css hides the
  toaster while projecting (covers thumbnail zoom AND act images)
- LeadsBoard: rows open a LeadDetail view (body via MarkdownViewer, recompensa,
  human-readable requisitos, clickable donde, plazo); transitions stopPropagation
- 4 e2e tests incl. the empty-innerText player-safety assertions + projecting flag
- gates: tsc clean, 411 unit, build, 87 e2e green
…music dock

Events (GM: too few, and they repeat even after being ignored/solved):
- WorldEvent.unico flag (once-only), authored as a bare token in the header
  attrs: ## vNN — Titulo {peso=N; unico; etiquetas=...}
- drawEvent(tables, ctx, rng, seenCounts?): seen unico events HARD-excluded;
  seen non-unico decay round(peso/(1+seen)) floored at 1; empty-after-exclusion
  falls back to the full pool so a draw always returns
- eventSeenCounts() tallies evento: <tabla>#<id> from the journal; page builds
  the tally from the live session + every model.diario journal, so an event
  resolved in a prior (not-yet-maintained) session won't refire; 'Otra tirada'
  excludes the just-shown event
- content: event tables 19 -> 61 across 7 tables (2 new: estancia_nebula_edge,
  viajes_nucleo), 23 marked unico; all canon-grounded, strong-r-clean :::leer

Music dock (GM: doesn't appear on the world screen):
- was mounted only when mundo/musica/ had tracks, so an empty folder showed
  NOTHING (read as broken). Now ALWAYS rendered; empty -> 'Sin música cargada'
  panel + hint to add .mp3 in mundo/musica/. No AudioManager when empty.

gates: tsc clean, 420 unit / 1220 assertions, build, 88 e2e; adversarial
200-draw no-repeat + all-unico fallback + real-eventos scan all green
…ative tracker

Ongoing events (GM: need to see currently-ongoing events after generating one):
- EventOutcome gains 'en_curso'; the drawer's 'En curso' button parks an event
  instead of closing the thread
- lib/world/ongoingEvents.ts deriveOngoing(): latest evento-state per id from the
  session journal; en curso -> ongoing, any resolution -> dropped; rebuilds after
  crash recovery. Ids missing from the model are skipped.
- new 'Eventos' right-panel tab (PanelTab gains eventos) with a count badge +
  EventosPanel listing ongoing events; row click reopens the event card to apply
  more efectos or finally resolve it

Cockpit combat (GM: initiative menu for fights on the world screen):
- estado/grupo.md personajes: [] parsed into PartyState.personajes and
  PRESERVED through every app-owned frontmatter rewrite (never dropped)
- cockpit InitiativeTracker (reused) fed the party roster, mounted only when no
  ActRunner is open (shared localStorage key = combat continuity cockpit<->act)
- EventDrawer 'Abrir combate' parks the event en_curso + brings up the tracker

gates: tsc clean, 436 unit / 1251 expects, build, 91 e2e; adversarial verify
(ongoing dedup, personajes non-drop, double-mount guard, panel migration)
…itiative

Discard test sessions (GM runs many test sessions that must not persist):
- FileSystemManager.deleteFile (idempotent; mockFs removeEntry)
- partyStore.discardSession(): surface-guarded journal delete, estado rolled
  back to the session-start snapshot (credits/gauges/day/location reverted,
  personajes + agent body preserved), mirror cleared, live fields reverted;
  delete-failure leaves the session active + untouched
- 'Terminar sesión' -> Guardar / Descartar (prueba); discard then rescans so
  in-memory knowledge/pista bumps revert and model.diario drops the file
- openWorld now resets extraDiarioRef on every scan (a discarded session no
  longer inflates the next session number — was s02, now correctly s01)

Force-open cockpit initiative (combat menu must open, not just a pull-tab):
- InitiativeTracker gains optional openSignal counter (additive; /play unchanged)
  -> Combate + 'Abrir combate' force-expand the full panel with the PC roster

tests: 4 partyStore discard cases (rollback, no-trace renumber, delete-fail-safe,
no-op) + e2e (discard wipes journal/reverts estado/renumbers s01; Combate opens
expanded with PCs). Fixed 2 recorder/hygiene tests for the new end-session UI.
gates: tsc clean, 444 unit, build, 92 e2e; /play suite green
The acto2 'Archivo lights the 3 nodes' beat had no in-app action: nodes
start desconocido and MoverDialog excludes desconocido, so the party could
not travel to them. EntityPanel now shows a 'Revelar' button on any
desconocido/rumoreado entity (session-gated) that logs sabe:->conocido,
bumps in-memory conocimiento, and re-derives lead accionability — so the GM
lights each ghost node live at the table, it becomes a Mover destination,
and its pista goes accionable. Reuses bumpConocimientoInMemory + rederiveLeads.

e2e: reveal a ghost sistema -> data-knowledge conocido -> appears in Mover.
gates: tsc clean, 444 unit, build, 93 e2e green.
…bate in quick-log bar

Three GM-cockpit features, built together and green as a unit:

- Shops: scanner reads optional tiendas/ subfolder per playable place
  (lib/world/shops.ts parseShop + checkShopRefs aviso on dangling pnj);
  ShopPanel + EntityPanel 'Tiendas' section; session-gated Comprar logs
  an exact 'gasto: N | compra: <articulo>' journal entry and decrements
  in-memory stock (scan stays source of truth; agent reconciles).

- In-app Resumen: scanner reads optional mundo/resumen.md like the
  manifest (model.resumen, never an entity). Header 'Resumen' button
  opens a read-only 'Anteriormente…' dialog via MarkdownViewer.

- Floating buttons folded in: Música + Combate now live inside
  QuickLogBar (never session-gated); WorldAudioDock is controlled by
  page-owned musicaOpen; Combate force-opens the initiative tracker via
  combatOpenSignal. Standalone floating button removed.

Gates: tsc clean, 458 unit pass (+14), build OK, 98 e2e pass (+5).
Third spatial map tier. Drilling into a place with POI children (child
lugares carrying poi:{x,y} local coords) now renders those POIs
spatially on the map, mirroring SystemView; places without poi children
keep the existing SiteList (regression-guarded).

- types/world.ts + worldScanner: PlaceEntity.poi?:{x,y}; scanner parses
  it via asCoords (malformed -> undefined, never throws).
- components/world/PlaceView.tsx: central hub glyph + POI nodes mapped
  from 0..100 local coords to a centered viewBox via EntityNode; a
  dashed 'sin ubicar' ring for children without poi; PartyMarker roll-up
  for party-at-place / party-at-POI; placeFitRadius for fit-to-view.
- uiStore: MapTier += 'place'; focusPlaceId + focusPlace/backToSystem;
  place-tier persistence with graceful degrade on a stale focusPlaceId;
  viewport memory keyed 'place:<id>'.
- worldNav: WorldTier += 'place'; placeHasPlano(); tierTargetFor routes
  a POI to its parent's Plano (Cmd/K search + ?e= deep-link land there);
  all non-POI targets unchanged.
- page.tsx: tier-view switch renders PlaceView; drill-in decision tree
  (sistema / plano-place / SiteList-fallback / leaf); breadcrumb
  sector->sistema->place; a SiteList always floats over the correct
  spatial backdrop instead of jumping to sector.
- bunfig.toml root '.' + package.json test script excludes e2e, so
  component unit tests (PlaceView.test) are now CI-visible.

Gates: tsc clean, 475 unit pass (+17, now incl. components/), build OK,
99 e2e pass (+1 Plano test). Two adversarially-found latent defects
(POI-with-children nav, fit-radius corner math) fixed at root cause.
…button

- Shops: each item price is now an editable field (data-shop-price-input,
  defaults to the listed price). Comprar charges the adjusted amount and
  journals it — 'compra: <articulo>' at base, or
  'compra: <articulo> (negociado, base <n>)' when haggled. parsePrice
  guards blank/NaN/negative (Comprar disabled, never journals NaN),
  truncates decimals, allows zero (comped); a 'base <n>' hint shows the
  deviation. Per-row edits reset on shop change and after each buy.
  onBuy is now (item, precio); credits reduce by the negotiated amount.
- QuickLogBar: removed the «Pista» button (it only jumped to the Pistas
  tab, still reachable from the right-panel tab strip) + its onPista prop
  and the page wiring, so Música + Combate read as the clean trailing
  actions. No orphan refs.

Gates: tsc clean, 475 unit, build OK, 100 e2e (+1 negotiation test). Two
adversarial verifiers (buy math/input safety, regression) both clean.
… nodes

- ShopPanel: item rows were a single flex line that overflowed the narrow
  right-panel (price clipped, Comprar pushed off). Restructured to a
  stacked layout — articulo + stock, nota, then the editable price + cr +
  base hint + Comprar on their own justify-between row. Fully visible down
  to ~300px; all data-* hooks + negotiation behavior unchanged.
- Map nodes: added affordance badges so the GM can scan a Plano/system and
  identify function at a glance — emerald ShoppingCart (has a tienda, or
  servicios mercado/contrabando) and sky MessageCircle (servicios
  informacion/ocio), above the glyph; the amber leads badge stays as the
  quest/rumor marker. lib/world/shops.ts affordancesFor(); page gates on
  knowledge (conocido/visitado only — never leaks on ghost/rumored nodes)
  and threads nodeIconsFor through Sector/System/Place views. StarMap gains
  a subtle legend (Tienda / Información / Misión·rumor).

Gates: tsc clean, 485 unit (+10 affordancesFor tests), build OK, 100 e2e.
Both adversarial verifiers (layout/overflow, icons/regression) clean.
New right-panel 'Hilos' tab (HilosBoard) listing the campaign's tramas
grouped by rol, each showing its child pistas (with donde + estado +
accionable), lugares clave, facciones, reloj (actual/max), and the body
beats (what happens as the clock advances). A per-trama 'Ver en el mapa'
toggle paints that thread on the sector map.

- lib/world/threads.ts: tramaThreadNodes(model, tramaId) resolves a
  trama's lugaresClave + pistas.donde to deduped SECTOR-root nodes in map
  pixels (via sectorNodeFor + coords*WORLD_SCALE); coord-less/absent
  dropped; never throws. Shared TRAMA_ROL_COLOR (principal=amber,
  secundaria=sky, ambiental=violet) so tab and map match.
- components/world/ThreadLayer.tsx: hub-and-spoke lines to the centroid +
  colored node halos + counter-scaled name pill; pointer-events-none.
- components/world/SectorView.tsx: new 'threads' overlay slot, BEHIND
  routes + nodes (clicks never stolen).
- uiStore: panelTab 'hilos' + toggling focusTrama(id) (drops to sector
  tier so the web shows) + persistence; stale focusTramaId paints nothing.
- WORLD_SCALE hoisted to lib/world/constants.ts (re-exported from
  SectorView) so the pure helper avoids importing the React component.

Gates: tsc clean, 492 unit (+7), build OK, 101 e2e (+1 Hilos). Both
adversarial verifiers (thread data/focus, regression/render) clean.
… (zoom/pan + grid overlay)

The GM can now project a battlemap with a real grid for players.

- sessionScanner: maps/ image files now collect into part.battlemaps
  (type 'image') across all three scan paths; maps/*.md still go to
  supportDocs, images/*.png still to part.images. worldScanner re-bases
  part.battlemaps onto the campaign root (splitByActs + prefix paths).
- components/world/BattlemapViewer.tsx: player-safe fullscreen viewer
  (ref-counted html[data-projecting], no filename leak) with pointer-pan
  + wheel zoom-to-cursor (clamped), and a toggleable square grid overlay
  (SVG pattern, cell size 10-400px, Alt/Shift-drag offset) that pans and
  zooms WITH the map so it stays aligned to the art.
- ActRunner: surfaces battlemaps as distinct Grid-icon tabs that open the
  BattlemapViewer; images/ PNGs still open the plain FullscreenImage.
- types/index.ts: Part gains battlemaps: FileReference[].

Gates: tsc clean, 493 unit (+1), build OK, 102 e2e (+1). Both adversarial
verifiers (viewer/player-safety, scanner/regression) clean. /play untouched.
…able overflow

The act tab row could reach ~15 tabs (Plan + images + battlemaps + up to
~9 support docs) that overflowed with no scroll affordance and no hint
more existed off-screen. Two fixes:

- Fichas dropdown: the many support-doc tabs (characters/threats/maps-md)
  collapse into ONE 'Fichas' dropdown-menu, grouped by category inferred
  from the file path (Personajes / Amenazas / Mapas / Otros) with section
  labels + icons. Each item preserves its ORIGINAL doc index and drives
  the same doc-<index> Tabs value (TabsContent unchanged); the trigger
  shows active + the open doc's label. No dropdown when there are 0 docs.
  Plan + images + battlemaps stay as direct tabs.
- ScrollableTabsRow: the remaining tab row is horizontally scrollable with
  left/right arrow buttons (shown only on real overflow, measured via
  ResizeObserver) + edge fade gradients, so overflow is always navigable
  and visible. Plus type icons (FileText/Image/Grid) to scan by kind.

lib/world/supportDocCategory.ts (pure, unit-tested). BattlemapViewer /
RunnerImage / player-safety untouched; /play untouched.

Gates: tsc clean, 499 unit (+6), build OK, 103 e2e (+1 Fichas). Both
adversarial verifiers (interaction/index-mapping, regression) clean.
A GM dry-run found the run-of-show / threads-map / cast sheets lived only
as side markdown files the GM had to keep open in another editor all
night. Mirror the Resumen pattern: a header 'Guías' dropdown surfaces the
curated GM play-aids in a read-only MarkdownViewer dialog, one tap.

- constants.ts: GUIA_FILES allowlist (_RUN_OF_SHOW / _MAPA_DE_HILOS /
  _REPARTO_DE_MANANA, with friendly titles).
- worldScanner: readGuias() (tolerant one-read-per-file like readResumen;
  absent/unreadable skipped, never throws) + firstHeading() (prefers the
  file's first '# heading' as title). Threaded model.guias through
  assembleModel.
- types/world.ts: Guia {id,titulo,content} + WorldModel.guias.
- page.tsx: header 'Guías' dropdown (shown only when guias present) →
  read-only dialog per sheet via MarkdownViewer. lucide Map aliased to
  MapIcon (was shadowing global Map).

Gates: tsc clean, 502 unit (+3), build OK, 105 e2e (+2). Both adversarial
verifiers clean. Resumen button/dialog untouched; /play untouched.
…nto the tracker

The #1 combat friction from the GM dry-run: at fight start the GM read a
threat's stat block from the Fichas dropdown then HAND-TYPED name/HP/AC
into an empty InitiativeTracker (~8-12 taps), and again mid-fight for the
Brote oleada. Now it's one tap.

- lib/world/threatStatblock.ts: parseThreatStatblock(md) -> {nombre, ca,
  pv, nivel, ataques} + hasStatblock(); tolerant Spanish CA/PV (also
  AC/HP) regex mirroring extractPCStats. scopedMatch() prefers the code
  fence so a reskin's base-creature prose line (Custodio 'AC 18/HP 45')
  no longer poisons the real fenced stats (CA 19/PV 48) — caught by the
  verify pass, fixed at root cause.
- InitiativeTracker: OPTIONAL addCombatants {nonce, combatants[]} prop
  (mirrors openSignal); appends NPC rows with maxHP/defense prefilled +
  #2/#3 de-dupe. /play never passes it → byte-identical (13/13 green).
- ActRunner: a threat ficha with a statblock shows an 'Añadir al combate'
  bar (name + CA/PV + 1-8 count) that parses + populates the tracker + a
  toast. Non-statblock / character / map docs show nothing.

Gates: tsc clean, 513 unit (+11), build OK, 107 e2e (+2). Verified live:
Custodio/Depredador/Guardián/Brote all parse correctly from the real
fichas. Both adversarial verifiers clean after fix.
Screen-safety for shared displays (a GM dry-run found acto2's 47-node
holo-map reveal leaks the :::gm answer-key rail to players).

- ActPanels: optional playerView prop (default false → byte-identical).
  When ON, TRUE conditional-renders away (not CSS-hide) every GM-only
  block so nothing can leak on a projected screen: the act-scoped :::gm
  left rail, the :::accion right rail, :::recurso cue chips, the action
  hint — AND section-scoped :::gm blocks rendered inline in the center
  (the verify pass caught this leak: bare :::gm under a numbered heading
  parses as a section block; guarded at ActPanels:285). Only the Plan act
  panel is affected; Fichas stay full GM view. /play never passes the prop.
- ActRunner: 'Vista jugador' header toggle (data-player-view, aria-pressed,
  Eye icon) so the GM knows the safe view is live; threaded only into the
  Plan RunnerMarkdown. Confirmed no phantom acto1 (auto-selects first real
  act) + an 'ENTRADA' badge on the first act tab.

Gates: tsc clean, 513 unit, build OK, 109 e2e (+2). /play 13/13 unchanged.
Both adversarial verifiers clean after the section-:::gm leak fix.
… transitions

The #1 ergonomics friction from both GM dry-runs: an act's DIARIO
state-transitions (reveal 3 nodes, flip 3 pistas rumor→activa, set rumbo;
a node acto4 closing its pista + cura_de_dax) were documented in :::gm
prose but the GM hand-performed them across THREE panels mid-narration —
'high odds he never comes back to flip the pistas', and an unlogged
transition silently reverts. Now acts declare them as :::efecto blocks
(same grammar as events) and ActRunner surfaces one-tap apply buttons.

- Reuse, one grammar/one apply path: exported extractEfectos (the SAME
  parser events use), stripEfectoBlocks + splitComentario from EventDrawer;
  split the page's effect handler into applyEffectCore(effect):boolean
  (shared with EventDrawer, no duplication).
- ActRunner: parses the current act's :::efecto → a GM-only 'Efectos del
  acto' panel of apply buttons; applied buttons check off + disable;
  session-gated (hint when inactive); HIDDEN under Vista jugador; no panel
  when the act has none; :::efecto stripped from the act view (no raw leak).
- Only ticks a button off when applyEffectCore actually journaled (returns
  true) — a rejected apply stays un-applied so nothing silently reverts
  (the exact failure the feature prevents; verify pass flagged the missing
  guard, fixed).

Gates: tsc clean, 515 unit (+2), build OK, 112 e2e (+3). Regression verifier
clean; /play (ActPanels) untouched; EventDrawer byte-identical.
From the GM dry-runs (cross-node high friction): multi-success challenges
('4 éxitos antes de 3 fallos' — Jardín B1.2; Coro Umbral 3-antes-de-2;
Pulso hack steps) had no in-app tally, so the GM tracked X/N successes and
Y/M fails on paper and miscounted the gate.

- lib/actFormat.ts: parseAccion parses an optional 'reto:' field (tolerant:
  '4 exitos / 3 fallos', '4/3', '4 exitos antes de 3 fallos'; never throws)
  → ActBlock { exitosMeta, fallosMeta }.
- ActPanels ActionCard: a RetoTracker — N emerald éxito pips + M rose fallo
  pips (tappable stepper), an 'éxitos X/N · fallos Y/M' readout, and
  '▶ SUPERADO' / '▶ FALLADO' at the threshold. Pure GM tally (no journal
  coupling); resets on act/section switch. Hidden under Vista jugador.
  Shared ActPanels stays byte-identical for reto-free accions (/play safe).

Gates: tsc clean, 523 unit (+8), build OK, 113 e2e (+1). Both adversarial
verifiers clean; /play 13/13 unchanged.
DialogContent defaults to display:grid, so the inner overflow-y-auto body
never received a bounded height — long recaps/guides clipped with no scroll.
Switch DialogContent to flex flex-col and give the body flex-1 + min-h-0 +
explicit max-h so it scrolls under a fixed header. Verified on the real
dialog (scrolls to RECORDATORIOS); build + 523 unit + 2 Resumen e2e green.
…unner

The fullscreen play overlay hid the cockpit, so a GM running an act lost
sight of créditos/medidores and couldn't spend/adjust for the supply &
repair beats that live INSIDE acts (porto_verne acto1 'Suministros y muelle'
+ 'Reparar el casco'). Add a compact party strip in the ActRunner header —
créditos (± popover) and manifest gauges (0-5 pip popover), both routed
through the SAME page callbacks as the QuickLogBar so adjustments journal —
plus a Tienda button that opens the place's shop(s) in a scrollable dialog
(reusing ShopPanel + handleBuy). Read-out always visible; edits session-gated.
New e2e locks it: strip shows, nave 2->5 journals a medidor, Tienda opens the
shop. Build + 523 unit + 52 e2e green; verified live in the real ActRunner.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant