diff --git a/.gitignore b/.gitignore
index 3886ad27b..f293c84df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ __pycache__/
# Test cache
.pytest_cache
+
+# Generated adventure logs and browser artifacts.
+/runs/
diff --git a/concordia/command_line_interface/concordia_session.py b/concordia/command_line_interface/concordia_session.py
new file mode 100644
index 000000000..ecb72cc75
--- /dev/null
+++ b/concordia/command_line_interface/concordia_session.py
@@ -0,0 +1,79 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Attach noninteractively to the same operation API used by the editor.
+
+No engine or domain behavior lives here. JSON output is always machine-readable.
+"""
+
+import argparse
+import json
+import pathlib
+import sys
+import urllib.error
+import urllib.request
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--url', required=True, help='Trusted local service URL')
+ parser.add_argument('command', choices=('discover', 'state', 'call', 'watch'))
+ parser.add_argument(
+ '--input', default='-', help='JSON request file or - for stdin'
+ )
+ parser.add_argument('--timeout', type=float, default=15)
+ args = parser.parse_args(argv)
+ endpoint = {
+ 'discover': 'operations',
+ 'state': 'state',
+ 'call': 'dispatch',
+ 'watch': 'events',
+ }[args.command]
+ try:
+ data = None
+ if args.command == 'call':
+ raw = (
+ sys.stdin.read()
+ if args.input == '-'
+ else pathlib.Path(args.input).read_text(encoding='utf-8')
+ )
+ data = json.dumps(json.loads(raw), ensure_ascii=False).encode()
+ request = urllib.request.Request(
+ args.url.rstrip('/') + '/api/' + endpoint,
+ data=data,
+ headers={'Content-Type': 'application/json'},
+ )
+ with urllib.request.urlopen(request, timeout=args.timeout) as response:
+ if args.command == 'watch':
+ for line in response:
+ if line.startswith(b'data: '):
+ print(line[6:].decode().strip(), flush=True)
+ else:
+ print(response.read().decode())
+ return 0
+ except urllib.error.HTTPError as error:
+ print(error.read().decode(), file=sys.stderr)
+ return 2
+ except (OSError, ValueError) as error:
+ print(
+ json.dumps(
+ {'error': {'code': 'transport_or_input', 'message': str(error)}}
+ ),
+ file=sys.stderr,
+ )
+ return 3
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/concordia/components/agent/__init__.py b/concordia/components/agent/__init__.py
index 848bef7c0..e2bcd65d0 100644
--- a/concordia/components/agent/__init__.py
+++ b/concordia/components/agent/__init__.py
@@ -19,6 +19,7 @@
from concordia.components.agent import concat
from concordia.components.agent import concat_act_component
from concordia.components.agent import constant
+from concordia.components.agent import human_act_component
from concordia.components.agent import instructions
from concordia.components.agent import memory
from concordia.components.agent import observation
diff --git a/concordia/components/agent/concat.py b/concordia/components/agent/concat.py
index ee66e2bcd..67c49712f 100644
--- a/concordia/components/agent/concat.py
+++ b/concordia/components/agent/concat.py
@@ -20,6 +20,38 @@
from concordia.typing import entity_component
+def validate_component_order(
+ component_order: Sequence[str] | None,
+) -> tuple[str, ...] | None:
+ """Snapshot an acting component's order, rejecting duplicate keys."""
+ order = tuple(component_order) if component_order is not None else None
+ if order is not None and len(set(order)) != len(order):
+ raise ValueError(
+ 'The component order contains duplicate components: ' + ', '.join(order)
+ )
+ return order
+
+
+def concat_contexts(
+ contexts: entity_component.ComponentContextMapping,
+ component_order: Sequence[str] | None = None,
+) -> str:
+ """Assemble pre-act contexts using ConcatAct's ordering and whitespace rules.
+
+ None uses mapping iteration order. An explicit order (including an empty
+ sequence) appends unspecified keys in sorted order. Empty values are omitted;
+ labels, whitespace and line breaks inside other values are kept verbatim.
+ Explicit keys absent from contexts raise KeyError. Constructors validate and
+ snapshot the order with validate_component_order.
+ """
+ if component_order is None:
+ return '\n'.join(context for context in contexts.values() if context)
+ order = tuple(component_order) + tuple(
+ sorted(set(contexts.keys()) - set(component_order))
+ )
+ return '\n'.join(contexts[name] for name in order if contexts[name])
+
+
class Concatenate(
action_spec_ignored.ActionSpecIgnored, entity_component.ComponentWithLogging
):
diff --git a/concordia/components/agent/concat_act_component.py b/concordia/components/agent/concat_act_component.py
index 8ea6276cb..94f6ea98a 100644
--- a/concordia/components/agent/concat_act_component.py
+++ b/concordia/components/agent/concat_act_component.py
@@ -15,8 +15,10 @@
"""A simple acting component that aggregates contexts from components."""
from collections.abc import Sequence
+from typing import cast
from typing import override
+from concordia.components.agent import concat
from concordia.document import interactive_document
from concordia.language_model import language_model
from concordia.typing import entity as entity_lib
@@ -52,9 +54,8 @@ def __init__(
assembled in the iteration order of the `ComponentContextMapping` passed
to `get_action_attempt`. If the component order is specified, but does
not contain all the components passed to `get_action_attempt`, the
- missing components will be appended at the end in the iteration order of
- the `ComponentContextMapping` passed to `get_action_attempt`. The same
- component cannot appear twice in the component order. All components in
+ missing components will be appended at the end in sorted key order.
+ The same component cannot appear twice in the component order. All components in
the component order must be in the `ComponentContextMapping` passed to
`get_action_attempt`.
prefix_entity_name: Whether to prefix the entity name to the output of
@@ -70,28 +71,13 @@ def __init__(
self._model = model
self._prefix_entity_name = prefix_entity_name
self._randomize_choices = randomize_choices
- if component_order is None:
- self._component_order = None
- else:
- self._component_order = tuple(component_order)
- if self._component_order is not None:
- if len(set(self._component_order)) != len(self._component_order):
- raise ValueError(
- 'The component order contains duplicate components: '
- + ', '.join(self._component_order)
- )
+ self._component_order = concat.validate_component_order(component_order)
def _context_for_action(
self,
contexts: entity_component.ComponentContextMapping,
) -> str:
- if self._component_order is None:
- return '\n'.join(context for context in contexts.values() if context)
- else:
- order = self._component_order + tuple(
- sorted(set(contexts.keys()) - set(self._component_order))
- )
- return '\n'.join(contexts[name] for name in order if contexts[name])
+ return concat.concat_contexts(contexts, self._component_order)
@override
def get_action_attempt(
@@ -179,8 +165,8 @@ def get_state(self) -> entity_component.ComponentState:
def set_state(self, state: entity_component.ComponentState) -> None:
if 'component_order' in state:
- order = state['component_order']
- self._component_order = tuple(order) if order else None # pyrefly: ignore[bad-argument-type]
+ order = cast(Sequence[str] | None, state['component_order'])
+ self._component_order = tuple(order) if order else None
if 'prefix_entity_name' in state:
self._prefix_entity_name = state['prefix_entity_name'] # pyrefly: ignore[bad-assignment]
if 'randomize_choices' in state:
diff --git a/concordia/components/agent/context_order_test.py b/concordia/components/agent/context_order_test.py
new file mode 100644
index 000000000..019140972
--- /dev/null
+++ b/concordia/components/agent/context_order_test.py
@@ -0,0 +1,136 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Human and LLM acting share Concat's exact established context semantics."""
+
+from unittest import mock
+
+from absl.testing import absltest
+from absl.testing import parameterized
+from concordia.agents import entity_agent_with_logging
+from concordia.components.agent import concat_act_component
+from concordia.components.agent import human_act_component
+from concordia.language_model import no_language_model
+from concordia.typing import entity as entity_lib
+
+
+class ContextOrderTest(parameterized.TestCase):
+
+ def policies(self, order):
+ reader = mock.Mock(return_value='LOOK')
+ model = mock.Mock(wraps=no_language_model.NoLanguageModel())
+ model.sample_text.return_value = 'LOOK'
+ human = human_act_component.HumanActComponent(reader, component_order=order)
+ llm = concat_act_component.ConcatActComponent(model, component_order=order)
+ for policy in (human, llm):
+ entity_agent_with_logging.EntityAgentWithLogging('Ilyra', policy)
+ return human, llm, reader, model
+
+ @parameterized.named_parameters(
+ ('mapping_order', None, {'z': 'Z', 'a': 'A'}, 'Z\nA'),
+ ('empty_order_sorts', [], {'z': 'Z', 'a': 'A'}, 'A\nZ'),
+ (
+ 'partial_order_sorts_remainder',
+ ['b'],
+ {'z': 'Z', 'b': 'B', 'a': 'A'},
+ 'B\nA\nZ',
+ ),
+ ('explicit_order', ['b', 'a'], {'a': 'A', 'b': 'B'}, 'B\nA'),
+ (
+ 'preserve_labels_and_whitespace',
+ ['z', 'empty'],
+ {'empty': '', 'z': '\nLabel:\n Z\n', 'a': ' A '},
+ '\nLabel:\n Z\n\n A ',
+ ),
+ ('empty_context', None, {}, ''),
+ )
+ def test_exact_context_matches_concat_prompt(self, order, contexts, expected):
+ human, llm, reader, model = self.policies(order)
+ spec = entity_lib.free_action_spec(call_to_action='Your move, {name}?')
+ human.get_action_attempt(contexts, spec)
+ llm.get_action_attempt(contexts, spec)
+ request = reader.call_args.args[0]
+ self.assertEqual(request.context, expected)
+ self.assertEqual(llm._context_for_action(contexts), expected)
+ self.assertEqual(
+ human.get_context_concat_order(), llm.get_context_concat_order()
+ )
+ prompt = model.sample_text.call_args.kwargs['prompt']
+ self.assertTrue(prompt.startswith(expected + '\n'), repr(prompt))
+ self.assertEqual(
+ human.get_entity().get_last_log()['__act__']['Context'], expected
+ )
+
+ @parameterized.parameters(
+ human_act_component.HumanActComponent,
+ concat_act_component.ConcatActComponent,
+ )
+ def test_duplicate_order_is_rejected(self, policy):
+ with self.assertRaisesRegex(ValueError, 'duplicate components: x, x'):
+ policy(mock.Mock(), component_order=['x', 'x'])
+
+ def test_explicit_missing_key_raises_without_asking_human_or_llm(self):
+ human, llm, reader, model = self.policies(['missing'])
+ for policy in (human, llm):
+ with self.assertRaises(KeyError):
+ policy.get_action_attempt(
+ {'present': 'value'}, entity_lib.DEFAULT_ACTION_SPEC
+ )
+ reader.assert_not_called()
+ model.sample_text.assert_not_called()
+
+ def test_order_and_request_context_are_snapshots_and_retries_keep_string(
+ self,
+ ):
+ order = ['b', 'a']
+ human, llm, reader, _ = self.policies(order)
+ order.reverse()
+ self.assertEqual(human.get_context_concat_order(), ('b', 'a'))
+ self.assertEqual(llm.get_context_concat_order(), ('b', 'a'))
+ contexts = {'a': 'A', 'b': 'B'}
+ requests = []
+
+ def read(request):
+ requests.append(request)
+ contexts['b'] = 'changed'
+ return '' if len(requests) == 1 else 'LOOK'
+
+ reader.side_effect = read
+ human.get_action_attempt(contexts, entity_lib.DEFAULT_ACTION_SPEC)
+ self.assertEqual([r.context for r in requests], ['B\nA', 'B\nA'])
+ self.assertEqual(requests[1].contexts['b'], 'B')
+
+ @parameterized.parameters((None,), ([],), (['b', 'a'],))
+ def test_order_state_format_and_partial_restore_match_concat(self, order):
+ human, llm, _, _ = self.policies(order)
+ self.assertEqual(
+ human.get_state()['component_order'], llm.get_state()['component_order']
+ )
+ # Retain Concat's legacy empty-sequence serialization/restore semantics.
+ for state in (
+ human.get_state(),
+ {},
+ {'component_order': []},
+ {'component_order': ['a']},
+ ):
+ human.set_state(state)
+ llm.set_state(state)
+ self.assertEqual(
+ human.get_context_concat_order(), llm.get_context_concat_order()
+ )
+ self.assertNotIn('input_reader', human.get_state())
+
+
+if __name__ == '__main__':
+ absltest.main()
diff --git a/concordia/components/agent/human_act_component.py b/concordia/components/agent/human_act_component.py
new file mode 100644
index 000000000..323d75826
--- /dev/null
+++ b/concordia/components/agent/human_act_component.py
@@ -0,0 +1,215 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""An acting component that asks a human instead of a language model."""
+
+from collections.abc import Sequence
+import dataclasses
+import json
+import math
+import types
+from typing import cast
+from typing import override
+import uuid
+
+from concordia.components.agent import concat
+from concordia.typing import entity as entity_lib
+from concordia.typing import entity_component
+from concordia.typing import human_input
+
+
+def validate_response(
+ response: str, action_spec: entity_lib.ActionSpec
+) -> None:
+ """Validate human input, including the sequential engine's GM contracts.
+
+ This is also available to adapters for immediate feedback before submission.
+ HumanActComponent always validates again at the component boundary.
+
+ Unlike language model sampling, human input must not silently fall back to
+ an invented action, a different choice, or NaN. Choices are exact option
+ values (not indices); free text and numbers are returned without rewriting.
+
+ Raises:
+ ValueError: If the human can correct an invalid response.
+ NotImplementedError: If the output type is unsupported.
+ """
+ if not isinstance(response, str):
+ raise ValueError('The response must be text.')
+ output_type = action_spec.output_type
+ if output_type == entity_lib.OutputType.SKIP_THIS_STEP:
+ if response:
+ raise ValueError('A skipped step must have an empty response.')
+ return
+ if output_type in entity_lib.CHOICE_ACTION_TYPES:
+ # Reuse ActionSpec validation for GM choice types as well as player choices.
+ dataclasses.replace(
+ action_spec, output_type=entity_lib.OutputType.CHOICE
+ ).validate(response)
+ return
+ if output_type == entity_lib.OutputType.FLOAT:
+ action_spec.validate(response)
+ if not math.isfinite(float(response)):
+ raise ValueError('Enter a finite number (not NaN or infinity).')
+ return
+ if output_type not in entity_lib.FREE_ACTION_TYPES:
+ raise NotImplementedError(f'Unsupported output type: {output_type}')
+ if not response.strip():
+ raise ValueError('Enter a non-empty response.')
+ if output_type != entity_lib.OutputType.NEXT_ACTION_SPEC:
+ dataclasses.replace(
+ action_spec, output_type=entity_lib.OutputType.FREE
+ ).validate(response)
+ return
+
+ # ActionSpec's constructor checks option cardinality but not JSON field
+ # types. Validate these before passing the object to the standard converter.
+ try:
+ value = json.loads(response)
+ except json.JSONDecodeError as exc:
+ raise ValueError('Enter a valid JSON action specification.') from exc
+ if not isinstance(value, dict):
+ raise ValueError('The action specification must be a JSON object.')
+ if not isinstance(value.get('call_to_action'), str):
+ raise ValueError('The action specification needs a text call_to_action.')
+ if not isinstance(value.get('output_type'), str):
+ raise ValueError('The action specification needs a text output_type.')
+ options = value.get('options', [])
+ if not isinstance(options, list) or not all(
+ isinstance(option, str) for option in options
+ ):
+ raise ValueError('options must be a JSON array of strings.')
+ if value.get('tag') is not None and not isinstance(value['tag'], str):
+ raise ValueError('tag must be text or null.')
+ try:
+ next_spec = entity_lib.action_spec_from_dict(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f'Invalid action specification: {exc}') from exc
+ if next_spec.output_type not in (
+ *entity_lib.PLAYER_ACTION_TYPES,
+ entity_lib.OutputType.SKIP_THIS_STEP,
+ ):
+ raise ValueError('The next action specification must be for a player.')
+ if (
+ next_spec.output_type != entity_lib.OutputType.SKIP_THIS_STEP
+ and not next_spec.call_to_action.strip()
+ ):
+ raise ValueError('Enter a non-empty call_to_action for the player.')
+
+
+class HumanActComponent(
+ entity_component.ActingComponent, entity_component.ComponentWithLogging
+):
+ """Ask the controller of this entity for every non-skipped action.
+
+ Install this in a normal EntityAgent or EntityAgentWithLogging, for either a
+ player or a GM. Other entities and all context-component lifecycle hooks are
+ unchanged. LLM-backed context components still run; use observation, memory,
+ constant, and other non-LLM components for a fully human-controlled player.
+
+ A human GM answers engine requests explicitly, including NEXT_ACTING,
+ NEXT_ACTION_SPEC, TERMINATE, and NEXT_GAME_MASTER. Context components may
+ supply advice but never silently substitute their answer for the human's.
+
+ The reader is a runtime dependency and is not serialized. Restore the
+ component with the same reader wiring. No pending request survives a process
+ restart; transport adapters must not replay old responses into a new run.
+ """
+
+ def __init__(
+ self,
+ input_reader: human_input.HumanInput,
+ component_order: Sequence[str] | None = None,
+ ):
+ """Initialize a human policy with the same context ordering as ConcatAct.
+
+ Args:
+ input_reader: Blocking transport receiving the full ordered context.
+ component_order: None uses context mapping order. An explicit sequence
+ appends unspecified keys sorted; absent keys raise KeyError at act time.
+ Duplicate keys raise ValueError here. Empty context values are omitted.
+ """
+ super().__init__()
+ self._input_reader = input_reader
+ self._component_order = concat.validate_component_order(component_order)
+
+ @override
+ def get_action_attempt(
+ self,
+ contexts: entity_component.ComponentContextMapping,
+ action_spec: entity_lib.ActionSpec,
+ ) -> str:
+ if action_spec.output_type == entity_lib.OutputType.SKIP_THIS_STEP:
+ return ''
+ if action_spec.output_type not in (
+ *entity_lib.FREE_ACTION_TYPES,
+ *entity_lib.CHOICE_ACTION_TYPES,
+ entity_lib.OutputType.FLOAT,
+ ):
+ raise NotImplementedError(
+ f'Unsupported output type: {action_spec.output_type}'
+ )
+ name = self.get_entity().name
+ request = human_input.HumanInputRequest(
+ request_id=uuid.uuid4().hex,
+ entity_name=name,
+ action_spec=dataclasses.replace(
+ action_spec,
+ call_to_action=action_spec.call_to_action.replace('{name}', name),
+ ),
+ contexts=types.MappingProxyType(dict(contexts)),
+ context=concat.concat_contexts(contexts, self._component_order),
+ )
+ while True:
+ response = self._input_reader(request)
+ try:
+ validate_response(response, action_spec)
+ except ValueError as exc:
+ request = dataclasses.replace(
+ request,
+ error=str(exc),
+ previous_response=response if isinstance(response, str) else None,
+ )
+ continue
+ self._logging_channel({
+ 'Summary': f'Action: {response}',
+ 'Value': response,
+ 'Prompt': (
+ (
+ request.context + '\n' + request.action_spec.call_to_action
+ ).splitlines()
+ ),
+ 'Context': request.context,
+ 'Source': 'human',
+ })
+ return response
+
+ def get_context_concat_order(self) -> Sequence[str] | None:
+ """Return the configured order, or None for mapping iteration order."""
+ return self._component_order
+
+ @override
+ def get_state(self) -> entity_component.ComponentState:
+ # Match ConcatAct's state format; the input reader remains runtime-only.
+ return {
+ 'component_order': (
+ list(self._component_order) if self._component_order else None
+ ),
+ }
+
+ @override
+ def set_state(self, state: entity_component.ComponentState) -> None:
+ if 'component_order' in state:
+ order = cast(Sequence[str] | None, state['component_order'])
+ self._component_order = tuple(order) if order else None
diff --git a/concordia/components/agent/human_act_component_test.py b/concordia/components/agent/human_act_component_test.py
new file mode 100644
index 000000000..5dbb5db67
--- /dev/null
+++ b/concordia/components/agent/human_act_component_test.py
@@ -0,0 +1,305 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Human input validation, entity lifecycle, and sequential GM contract tests."""
+
+import json
+from typing import cast
+from unittest import mock
+
+from absl.testing import absltest
+from absl.testing import parameterized
+from concordia.agents import entity_agent_with_logging
+from concordia.components.agent import concat_act_component
+from concordia.components.agent import constant
+from concordia.components.agent import human_act_component
+from concordia.environment import engine
+from concordia.environment.engines import sequential
+from concordia.language_model import no_language_model
+from concordia.typing import entity as entity_lib
+from concordia.typing import entity_component
+
+OutputType = entity_lib.OutputType
+
+
+def _make_human(reader, name='Wayfarer', context_components=None):
+ return entity_agent_with_logging.EntityAgentWithLogging(
+ agent_name=name,
+ act_component=human_act_component.HumanActComponent(reader),
+ context_components=context_components or {},
+ )
+
+
+class HumanActComponentTest(parameterized.TestCase):
+
+ def test_retries_without_reentering_context_lifecycle(self):
+ requests = []
+ answers = iter(['', 'open the silver door'])
+ context = constant.Constant('A silver door bars your path.')
+ original_pre_act = context.pre_act
+ with mock.patch.object(
+ context, 'pre_act', wraps=original_pre_act
+ ) as pre_act:
+
+ def read(request):
+ requests.append(request)
+ return next(answers)
+
+ human = _make_human(read, context_components={'Scene': context})
+ result = human.act(
+ entity_lib.free_action_spec(
+ call_to_action='What does {name} do? Literal JSON: {"door": 1}'
+ )
+ )
+ self.assertEqual(result, 'open the silver door')
+ self.assertEqual(pre_act.call_count, 1)
+ self.assertEqual(human.get_phase(), entity_component.Phase.READY)
+ self.assertEqual(requests[0].request_id, requests[1].request_id)
+ self.assertIsNone(requests[0].error)
+ self.assertIn('non-empty', requests[1].error)
+ self.assertEqual(requests[1].previous_response, '')
+ self.assertEqual(requests[0].entity_name, 'Wayfarer')
+ self.assertEqual(
+ requests[0].action_spec.call_to_action,
+ 'What does Wayfarer do? Literal JSON: {"door": 1}',
+ )
+ self.assertIn('silver door', requests[0].contexts['Scene'])
+ with self.assertRaises(TypeError):
+ requests[0].contexts['Scene'] = 'Changed'
+ self.assertEqual(human.get_last_log()['__act__']['Value'], result)
+ self.assertEqual(human.get_last_log()['__act__']['Source'], 'human')
+
+ @parameterized.parameters(*entity_lib.CHOICE_ACTION_TYPES)
+ def test_player_and_gm_choices_are_exact_and_retry(self, output_type):
+ reader = mock.Mock(side_effect=['0', 'north', ' North ', 'North'])
+ human = _make_human(reader)
+ self.assertEqual(
+ human.act(
+ entity_lib.ActionSpec(
+ call_to_action='Choose',
+ output_type=output_type,
+ options=('North', 'South'),
+ )
+ ),
+ 'North',
+ )
+ self.assertEqual(reader.call_count, 4)
+ self.assertIn('not one of', reader.call_args.args[0].error)
+
+ def test_options_with_whitespace_are_not_rewritten(self):
+ human = _make_human(lambda request: ' North ')
+ self.assertEqual(
+ human.act(
+ entity_lib.choice_action_spec(
+ call_to_action='Choose', options=(' North ', 'South')
+ )
+ ),
+ ' North ',
+ )
+
+ @parameterized.parameters('nan', 'NaN', 'inf', '-Infinity', '1e9999', 'three')
+ def test_float_rejects_non_finite_and_invalid_input(self, bad):
+ reader = mock.Mock(side_effect=[bad, '-1.25e2'])
+ self.assertEqual(
+ _make_human(reader).act(
+ entity_lib.float_action_spec(call_to_action='Enter a number')
+ ),
+ '-1.25e2',
+ )
+ self.assertIsNotNone(reader.call_args.args[0].error)
+
+ @parameterized.parameters(
+ OutputType.FREE, OutputType.MAKE_OBSERVATION, OutputType.RESOLVE
+ )
+ def test_free_types_reject_blank_and_preserve_human_wording(
+ self, output_type
+ ):
+ reader = mock.Mock(side_effect=[' \n ', ' A door opens.\n'])
+ result = _make_human(reader).act(
+ entity_lib.ActionSpec(
+ call_to_action='Describe', output_type=output_type
+ )
+ )
+ self.assertEqual(result, ' A door opens.\n')
+
+ @parameterized.parameters(None, 123, (['answer'],))
+ def test_non_text_input_gets_feedback(self, bad):
+ reader = mock.Mock(side_effect=[bad, 'look'])
+ self.assertEqual(_make_human(reader).act(), 'look')
+ self.assertIsNone(reader.call_args.args[0].previous_response)
+ self.assertIn('text', reader.call_args.args[0].error)
+
+ def test_skip_does_not_ask_for_input(self):
+ reader = mock.Mock()
+ human = _make_human(reader)
+ self.assertEqual(human.act(entity_lib.skip_this_step_action_spec()), '')
+ reader.assert_not_called()
+ self.assertEqual(human.get_phase(), entity_component.Phase.READY)
+
+ def test_unsupported_type_fails_before_input(self):
+ reader = mock.Mock()
+ human = _make_human(reader)
+ with self.assertRaises(NotImplementedError):
+ human.act(
+ entity_lib.ActionSpec(
+ call_to_action='Unsupported',
+ output_type=cast(entity_lib.OutputType, 'future'),
+ )
+ )
+ reader.assert_not_called()
+
+ @parameterized.parameters(EOFError, TimeoutError, KeyboardInterrupt)
+ def test_reader_failure_is_not_an_action(self, failure):
+ reader = mock.Mock(side_effect=failure)
+ with self.assertRaises(failure):
+ _make_human(reader).act()
+ reader.assert_called_once()
+
+ def test_requests_are_distinct_and_entity_routed(self):
+ requests = []
+
+ def read(request):
+ requests.append(request)
+ return 'look'
+
+ one = _make_human(read, 'One')
+ two = _make_human(read, 'Two')
+ one.act()
+ two.act()
+ one.act()
+ self.assertEqual([r.entity_name for r in requests], ['One', 'Two', 'One'])
+ self.assertLen({r.request_id for r in requests}, 3)
+
+ def test_entity_state_restores_without_serializing_transport(self):
+ reader = mock.Mock(return_value='look')
+ human = _make_human(reader)
+ state = human.get_state()
+ json.dumps(state)
+ restored = _make_human(reader)
+ restored.set_state(state)
+ self.assertEqual(restored.act(), 'look')
+
+ def test_other_entities_keep_normal_act_components(self):
+ reader = mock.Mock(return_value='South')
+ human = _make_human(reader)
+ npc = entity_agent_with_logging.EntityAgentWithLogging(
+ agent_name='Companion',
+ act_component=concat_act_component.ConcatActComponent(
+ model=no_language_model.NoLanguageModel(),
+ randomize_choices=False,
+ ),
+ )
+ spec = entity_lib.choice_action_spec(
+ call_to_action='Where next?', options=('North', 'South')
+ )
+ self.assertEqual(human.act(spec), 'South')
+ self.assertEqual(npc.act(spec), 'North')
+ reader.assert_called_once()
+
+
+class HumanGameMasterTest(parameterized.TestCase):
+
+ @parameterized.parameters(
+ 'not JSON',
+ 'null',
+ '[]',
+ '{}',
+ '{"call_to_action": 1, "output_type": "free"}',
+ '{"call_to_action": "Act", "output_type": 1}',
+ '{"call_to_action": "Act", "output_type": "unknown"}',
+ '{"call_to_action": "Act", "output_type": "choice", "options": []}',
+ '{"call_to_action": "Act", "output_type": "choice", "options": ["A",'
+ ' "A"]}',
+ '{"call_to_action": "Act", "output_type": "choice", "options": "AB"}',
+ '{"call_to_action": "Act", "output_type": "choice", "options": [1]}',
+ '{"call_to_action": "Act", "output_type": "free", "options": ["A"]}',
+ '{"call_to_action": "Act", "output_type": "free", "tag": 1}',
+ '{"call_to_action": "Act", "output_type": "resolve"}',
+ '{"call_to_action": "Act", "output_type": "free", "unexpected": true}',
+ '{"call_to_action": " ", "output_type": "free"}',
+ )
+ def test_next_action_spec_rejects_invalid_payloads(self, response):
+ with self.assertRaises(ValueError):
+ human_act_component.validate_response(
+ response,
+ entity_lib.ActionSpec(
+ call_to_action='Supply the player action spec as JSON.',
+ output_type=OutputType.NEXT_ACTION_SPEC,
+ ),
+ )
+
+ @parameterized.parameters(
+ entity_lib.free_action_spec(call_to_action='What does {name} do?'),
+ entity_lib.choice_action_spec(
+ call_to_action='Which door?', options=('silver, blue', 'gold')
+ ),
+ entity_lib.float_action_spec(call_to_action='How much?'),
+ entity_lib.skip_this_step_action_spec(),
+ )
+ def test_sequential_engine_accepts_human_next_action_spec(self, player_spec):
+ reader = mock.Mock(
+ side_effect=[
+ 'Wayfarer',
+ '{}',
+ engine.action_spec_to_string(player_spec),
+ ]
+ )
+ gm = _make_human(reader, 'GM')
+ player = _make_human(mock.Mock())
+ selected, actual_spec = sequential.Sequential().next_acting(gm, [player])
+ self.assertIs(selected, player)
+ self.assertEqual(actual_spec, player_spec)
+ self.assertIsNotNone(reader.call_args.args[0].error)
+ self.assertEqual(gm.get_phase(), entity_component.Phase.READY)
+
+ def test_sequential_observation_resolution_termination_and_gm_selection(self):
+ reader = mock.Mock(
+ side_effect=[
+ 'You stand beneath two moons.',
+ 'The silver door opens.',
+ 'no',
+ 'No',
+ 'Yes',
+ 'Second GM',
+ ]
+ )
+ gm = _make_human(reader, 'GM')
+ player = _make_human(mock.Mock())
+ second_gm = _make_human(mock.Mock(), 'Second GM')
+ env = sequential.Sequential()
+ self.assertEqual(
+ env.make_observation(gm, player), 'You stand beneath two moons.'
+ )
+ env.resolve(gm, 'Wayfarer opens the silver door.')
+ self.assertFalse(env.terminate(gm))
+ self.assertTrue(env.terminate(gm))
+ self.assertIs(env.next_game_master(gm, [gm, second_gm]), second_gm)
+ self.assertEqual(
+ [
+ call.args[0].action_spec.output_type
+ for call in reader.call_args_list
+ ],
+ [
+ OutputType.MAKE_OBSERVATION,
+ OutputType.RESOLVE,
+ OutputType.TERMINATE,
+ OutputType.TERMINATE,
+ OutputType.TERMINATE,
+ OutputType.NEXT_GAME_MASTER,
+ ],
+ )
+
+
+if __name__ == '__main__':
+ absltest.main()
diff --git a/concordia/contrib/language_models/ollama/ollama_model.py b/concordia/contrib/language_models/ollama/ollama_model.py
index f3ad63f26..088b76674 100644
--- a/concordia/contrib/language_models/ollama/ollama_model.py
+++ b/concordia/contrib/language_models/ollama/ollama_model.py
@@ -14,16 +14,16 @@
"""Ollama Language Model, a wrapper for models running on the local machine."""
-from collections.abc import Collection, Sequence
+from collections.abc import Collection, Mapping, Sequence
+import copy
import json
-from typing import override
+from typing import Any, Literal, override, TypedDict
from concordia.language_model import language_model
from concordia.utils import measurements as measurements_lib
from concordia.utils import sampling
import ollama
-
_MAX_MULTIPLE_CHOICE_ATTEMPTS = 20
_DEFAULT_TEMPERATURE = 0.5
_DEFAULT_TERMINATORS = ()
@@ -39,6 +39,10 @@
)
+class _TextFormatOptions(TypedDict, total=False):
+ format: Literal['json'] | dict[str, Any]
+
+
class OllamaLanguageModel(language_model.LanguageModel):
"""Language Model that uses Ollama LLM models."""
@@ -49,6 +53,9 @@ def __init__(
system_message: str = _DEFAULT_SYSTEM_MESSAGE,
measurements: measurements_lib.Measurements | None = None,
channel: str = language_model.DEFAULT_STATS_CHANNEL,
+ request_timeout: float | None = None,
+ max_output_tokens: int | None = None,
+ response_format: str | Mapping[str, Any] | None = None,
) -> None:
"""Initializes the instance.
@@ -59,9 +66,28 @@ def __init__(
model.
measurements: The measurements object to log usage statistics to.
channel: The channel to write the statistics to.
+ request_timeout: Optional HTTP timeout in seconds for local requests.
+ max_output_tokens: Upper bound, including callers with larger limits.
+ response_format: Optional sample_text format: 'json' or an Ollama JSON
+ schema. Omitted by default; sample_choice retains its own format.
+ The provider constrains shape, not factual or semantic correctness.
"""
+ if (
+ response_format is not None
+ and response_format != 'json'
+ and not (isinstance(response_format, Mapping))
+ ):
+ raise ValueError("response_format must be 'json', a schema or None")
+ self._text_format_options: _TextFormatOptions = {}
+ if isinstance(response_format, Mapping):
+ self._text_format_options['format'] = copy.deepcopy(dict(response_format))
+ elif response_format == 'json':
+ self._text_format_options['format'] = 'json'
self._model_name = model_name
- self._client = ollama.Client()
+ self._client = ollama.Client(timeout=request_timeout)
+ if max_output_tokens is not None and max_output_tokens <= 0:
+ raise ValueError('max_output_tokens must be positive')
+ self._max_output_tokens = max_output_tokens
self._system_message = system_message
self._terminators = []
@@ -81,7 +107,7 @@ def sample_text(
timeout: float = -1,
seed: int | None = None,
) -> str:
- del max_tokens, timeout, seed # Unused.
+ del timeout, seed # HTTP timeout is configured on the shared client.
prompt_with_system_message = f'{self._system_message}\n\n{prompt}'
@@ -92,11 +118,17 @@ def sample_text(
prompt=prompt_with_system_message,
options={
'stop': terminators,
+ 'num_predict': (
+ min(max_tokens, self._max_output_tokens)
+ if self._max_output_tokens is not None
+ else max_tokens
+ ),
'temperature': temperature,
'top_p': top_p,
'top_k': top_k,
},
keep_alive='10m',
+ **self._text_format_options,
)
result = response['response']
@@ -132,7 +164,15 @@ def sample_choice(
f'{prompt_with_system_message}.\n'
f'Use the following json template: {json.dumps(template)}.'
),
- options={'stop': (), 'temperature': temperature},
+ options={
+ 'stop': (),
+ 'temperature': temperature,
+ **(
+ {'num_predict': self._max_output_tokens}
+ if self._max_output_tokens is not None
+ else {}
+ ),
+ },
format='json',
keep_alive='10m',
)
diff --git a/concordia/contrib/language_models/ollama/ollama_model_test.py b/concordia/contrib/language_models/ollama/ollama_model_test.py
new file mode 100644
index 000000000..3c1008a25
--- /dev/null
+++ b/concordia/contrib/language_models/ollama/ollama_model_test.py
@@ -0,0 +1,102 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bounded local generation request checks; no model calls."""
+
+from unittest import mock
+
+from concordia.contrib.language_models.ollama import ollama_model
+import pytest
+
+
+@pytest.mark.parametrize(
+ 'bound,requested,expected',
+ [(None, 25, 25), (256, 2200, 256), (256, 12, 12)],
+)
+def test_generation_honors_requested_and_configured_limits(
+ bound, requested, expected
+):
+ with mock.patch.object(ollama_model.ollama, 'Client') as client:
+ client.return_value.generate.return_value = {'response': 'local response'}
+ model = ollama_model.OllamaLanguageModel(
+ 'local-model', request_timeout=90, max_output_tokens=bound
+ )
+ assert model.sample_text('prompt', max_tokens=requested) == 'local response'
+ client.assert_called_once_with(timeout=90)
+ assert (
+ client.return_value.generate.call_args.kwargs['options']['num_predict']
+ == expected
+ )
+
+
+def test_nonpositive_limit_rejected():
+ with mock.patch.object(ollama_model.ollama, 'Client'):
+ with pytest.raises(ValueError, match='positive'):
+ ollama_model.OllamaLanguageModel('local-model', max_output_tokens=0)
+
+
+@pytest.mark.parametrize('bound', [None, 256])
+def test_choice_generation_obeys_optional_limit(bound):
+ with mock.patch.object(ollama_model.ollama, 'Client') as client:
+ client.return_value.generate.return_value = {'response': '{"choice": "a"}'}
+ model = ollama_model.OllamaLanguageModel(
+ 'local-model', max_output_tokens=bound
+ )
+ assert model.sample_choice('prompt', ['a', 'b'])[:2] == (0, 'a')
+ options = client.return_value.generate.call_args.kwargs['options']
+ if bound is None:
+ assert 'num_predict' not in options
+ else:
+ assert options['num_predict'] == bound
+
+
+@pytest.mark.parametrize('response_format', [None, 'json', {'type': 'object'}])
+def test_text_format_is_opt_in_and_choice_keeps_its_own_json_contract(
+ response_format,
+):
+ with mock.patch.object(ollama_model.ollama, 'Client') as client:
+ client.return_value.generate.return_value = {'response': '{"choice":"a"}'}
+ model = ollama_model.OllamaLanguageModel(
+ 'local-model', response_format=response_format
+ )
+ model.sample_text('prompt')
+ args = client.return_value.generate.call_args.kwargs
+ if response_format is None:
+ assert 'format' not in args
+ else:
+ assert args['format'] == response_format
+ assert model.sample_choice('choice prompt', ['a', 'b'])[:2] == (0, 'a')
+ assert client.return_value.generate.call_args.kwargs['format'] == 'json'
+
+
+def test_caller_schema_mutation_cannot_change_subsequent_requests():
+ with mock.patch.object(ollama_model.ollama, 'Client') as client:
+ client.return_value.generate.return_value = {'response': '{}'}
+ schema = {'type': 'object', 'required': ['answer']}
+ model = ollama_model.OllamaLanguageModel(
+ 'local-model', response_format=schema
+ )
+ schema['required'].clear()
+ model.sample_text('prompt')
+ assert client.return_value.generate.call_args.kwargs['format'] == {
+ 'type': 'object',
+ 'required': ['answer'],
+ }
+
+
+def test_unknown_text_format_rejected_before_client_creation():
+ with mock.patch.object(ollama_model.ollama, 'Client') as client:
+ with pytest.raises(ValueError, match='response_format'):
+ ollama_model.OllamaLanguageModel('local-model', response_format='xml')
+ client.assert_not_called()
diff --git a/concordia/examples/__init__.py b/concordia/examples/__init__.py
new file mode 100644
index 000000000..f21909e9c
--- /dev/null
+++ b/concordia/examples/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Runnable examples of core Concordia components."""
diff --git a/concordia/examples/astral_canticle/README.md b/concordia/examples/astral_canticle/README.md
new file mode 100644
index 000000000..6ee5abc8e
--- /dev/null
+++ b/concordia/examples/astral_canticle/README.md
@@ -0,0 +1,217 @@
+# The Astral Canticle — a human in the loop
+
+A mobile-friendly, Zork-like science-fantasy adventure. You control **Ilyra Venn**;
+**Sable-9** and **Thorn of Io** retain their own language-model acting policies.
+The GM uses the normal `SwitchAct` component. No player action is invented by an
+LLM, and there are no lobbies, accounts, multiplayer sessions or paid services.
+
+The Auric Vesper / sleeping moon / star-loom setting is a small demonstration
+of human input rather than a research experiment. The opening scene has inspectable objects,
+connected locations and command suggestions. Narrative and consequences are
+model-generated, not a hard-coded puzzle or a deterministic inventory simulator.
+
+## Run locally
+
+From the repository root, with Python 3.12+ and a running local Ollama:
+
+```sh
+python -m venv .venv
+.venv/bin/python -m pip install -e '.[ollama]'
+.venv/bin/python -m pip install -r concordia/examples/astral_canticle/requirements.txt
+ollama pull llama3.2:3b
+.venv/bin/python -m concordia.examples.astral_canticle.web \
+ --model llama3.2:3b --port 8767 --max-steps 30 --output runs/human
+```
+
+Open . The command starts exactly **one** adventure. Page
+loads/reloads never launch or reset a simulation. A turn waits indefinitely for
+you; model responses can take a while. Other entities act once each between your
+turns. The chapter ends after `--max-steps` entity actions (not human rounds).
+
+Try **EXAMINE the resonance cradle**, **TAKE the tuning fork**, **INVENTORY**,
+**WEST**, **TALK TO Sable-9**, or describe an idea in ordinary words. Suggestions
+fill the command box; **Act** submits. Enter submits; Shift+Enter inserts a line.
+**Guide**, **Aa** and **Journal** provide help, larger text, and a transcript export.
+
+A dropped phone connection does not cancel a prompt. Reload reconnects to the
+same process, and drafts are retained in that browser's local storage. Responses
+carry unique request IDs: stale actions are rejected and retries of an accepted
+POST do not act twice. Drafts are not automatically sent after reconnecting.
+
+Completed steps are written as standard `SimulationLog` artifacts in the output
+directory: `simulation.json` and a self-contained `log.html`. These contain all
+entities' detailed logs and are **host-side only**. The player download contains
+only that player's observations/actions. A host restart starts a **new chapter**;
+old prompt IDs cannot be replayed. This example does not resume an interrupted
+simulation process from a checkpoint.
+
+## Select the human basic prefab
+
+Add `--player-prefab basic` to either the web or terminal command:
+
+```sh
+.venv/bin/python -m concordia.examples.astral_canticle.web \
+ --player-prefab basic --model llama3.2:3b --port 8769 \
+ --max-steps 30 --output runs/human-basic
+```
+
+Ilyra is built directly by the library's `concordia.prefabs.entity.basic.Entity`.
+Only `ConcatActComponent` is replaced with `HumanActComponent`; the prefab's
+instructions, observation-to-memory, observation history, goal,
+`SituationPerception`, `SelfPerception`, `PersonBySituation`, memory settings and
+logging all remain unchanged. **The three perception components still call the
+LLM before each human prompt.** The complete assembled context is visible under
+**Your context** on the phone, including all three perception answers. It is one
+verbatim string in the basic prefab's own Concat order, with original labels and
+line breaks. It also appears in the standard host-side logs. The Journal download
+remains your observations and submitted actions. The final action is always your
+input, not the model's suggested behavior.
+Sable-9, Thorn and the GM keep their existing composition and policies.
+
+The default remains `--player-prefab minimal`, the previous lightweight
+configuration with no LLM-based player context processing. In `--role gm`, this
+option still selects Ilyra's prefab, but Ilyra uses its normal LLM acting policy
+and the human controls the GM. Python callers can use
+`build_cast(..., player_prefab='basic')` or `play(..., player_prefab='basic')`.
+
+The library injection is reusable independently of this example or transport:
+
+```python
+from concordia.components.agent.human_act_component import HumanActComponent
+from concordia.prefabs.entity import basic
+
+player = basic.Entity(params={'name': 'Ilyra Venn', 'goal': 'Repair the loom.'}).build(
+ model=model,
+ memory_bank=memory_bank,
+ act_component_factory=lambda order: HumanActComponent(read, component_order=order),
+)
+```
+
+The factory receives the exact order calculated inside the prefab (including
+the goal placement); no ordering list is duplicated in the example. Both basic
+and minimal also accept a prebuilt `act_component`, but the two options are
+mutually exclusive. Omitting both leaves the library's default `ConcatActComponent`
+(including its ordering and choice/name-prefix options) unchanged. No parallel
+copy or subclass of the basic prefab is maintained.
+
+## Phone access on an existing tailnet
+
+The web server binds **only to 127.0.0.1**. Use Tailscale Serve, not Funnel. Inspect
+current configuration first and add only the new path; never reset other routes.
+For a host named `your-host.your-tailnet.ts.net`:
+
+```sh
+tailscale serve status --json
+# Include these flags on the web command above:
+# --allowed-host your-host.your-tailnet.ts.net --root-path /astral-canticle-play
+tailscale serve --bg --set-path /astral-canticle-play http://127.0.0.1:8767/astral-canticle-play
+```
+
+Open `https://your-host.your-tailnet.ts.net/astral-canticle-play/` on your phone.
+The trailing slash matters for relative asset URLs. The proxy target preserves the mount
+prefix; the adapter also accepts prefix-stripping proxies. Same-origin JSON
+POSTs, host validation and a restrictive CSP protect the browser boundary. This
+is a **single trusted tailnet controller**, not an authorization system: anyone
+with access to this route shares control. Do not expose it publicly. Existing
+`/astral-canticle`, `/astral-canticle-data`, and `/game-design-forum` are separate
+log/forum routes and need not be changed.
+
+## Human game master and terminal input
+
+Launch with `--role gm` to control the GM instead. All three player entities then
+use their default LLM policies. You provide observations, pick actors, create
+valid next-action specs, resolve attempts and decide termination. GM decisions
+are never silently filled in by model-based context components. Choice buttons
+use exact option values; the action-spec builder creates validated JSON. Both
+roles see only the controlled entity's complete pre-act context; unrelated NPC or
+GM component outputs are never added to it. This is a developer-oriented secondary UX;
+the default player mode is the polished single-human experience.
+
+The same adventure also works without **any web dependencies**:
+
+```sh
+.venv/bin/python -m concordia.examples.astral_canticle.terminal --role player
+# Use --role gm for terminal GM control.
+```
+
+A minimal transport implementation is simply a callable:
+
+```python
+from concordia.components.agent.human_act_component import HumanActComponent
+
+def read(request):
+ print(request.context) # Already ordered and labeled; do not reassemble the map.
+ if request.error:
+ print(request.error)
+ return input(request.action_spec.call_to_action + '> ')
+
+act_component = HumanActComponent(read)
+# Install in EntityAgentWithLogging, or pass to minimal.Entity.build(
+# model=model, memory_bank=memory_bank, act_component=act_component).
+```
+
+`concordia.typing.human_input.HumanInputRequest` contains the entity name, unique
+request ID, immutable context map, preassembled `context` string, action spec and
+retry feedback. Core has no knowledge of HTTP, browsers, threads or Tailscale. Independent readers can be
+wired to additional human entities in a future application; this example keeps
+one inbox per controller and deliberately implements no multiplayer routing.
+
+Only `{name}` in a call-to-action is interpolated, without corrupting literal
+JSON braces. Free prose must be nonempty, numbers finite, choices exact, and a
+GM's `NEXT_ACTION_SPEC` must decode to a valid player `ActionSpec`. Skip actions
+return without requesting input. Cancellation/EOF is raised, never converted to
+an invented action. Context lifecycle and standard logging remain unchanged.
+LLM-based context components can still use a model when someone composes a
+human entity with them; the default minimal selection uses only non-LLM human
+context components, while basic preserves its LLM-backed perceptions.
+
+### Context ordering contract
+
+`HumanActComponent(reader, component_order=None)` uses the context mapping's
+iteration order. An explicit sequence uses that order first, then appends
+unspecified keys **alphabetically**, exactly as the existing Concat implementation
+does. Empty strings are skipped; all other labels, spaces and line breaks remain
+intact. Explicitly named absent keys raise `KeyError` when acting; duplicate keys
+raise `ValueError` at construction. `get_context_concat_order()` returns the
+snapshotted order, and state saves/restores it with Concat's existing conventions
+(including its empty-order-to-None state normalization). The reader is not saved.
+
+Both policies call the same standard `concat.concat_contexts` helper; transports
+render `request.context` verbatim rather than adding dictionary headings. The
+player and GM UI both show it expanded in the scrolling page (not a cramped
+composer panel), and terminal input prints the very same string. Context only
+comes from the entity attached to this HumanActComponent. Showing the human's
+own perception outputs does not expose another entity's private components.
+
+## Composition and verification
+
+The example reuses `minimal.Entity` / `basic.Entity`, `EntityAgentWithLogging`, observation/memory
+components, `SwitchAct`, `NextActingInFixedOrder`, `FixedActionSpec`,
+`Sequential`, `SimulationLog` and its standard `to_html()` renderer. A constant small
+embedding is sufficient because this composition retrieves chronological
+observations, not semantic neighbors. The GM sees the recent event history and
+setting; we do not duplicate an engine, event resolver, clock or logging system.
+Unlike the heavier time-and-place prefab, the lean GM tracks the Canticle Clock
+in narrative context rather than generating separate clock/location LLM calls.
+
+```sh
+.venv/bin/python -m pip install pytest pytest-xdist pyink isort
+.venv/bin/python -m pip install -r concordia/examples/astral_canticle/requirements-test.txt
+.venv/bin/python -m playwright install chromium
+.venv/bin/python -m pytest -n 0 \
+ concordia/components/agent/human_act_component_test.py \
+ concordia/components/agent/context_order_test.py \
+ concordia/environment/engines/sequential_test.py \
+ concordia/prefabs/entity/basic_test.py \
+ concordia/prefabs/entity/prefabs_test.py \
+ concordia/examples/astral_canticle
+```
+
+Unit and browser tests use mock models or transport requests, never a live model.
+They cover exact Concat/Human context/order parity, full basic perception outputs
+visible verbatim in player/GM mobile views, basic/default component parity and
+perception lifecycle, CLI selection, player/GM contracts, normal NPC policies, a complete standard-engine
+round, stale/duplicate actions, cross-origin rejection, reconnects, draft recovery,
+mobile layout, safe text rendering, and GM action-spec controls. Web tests skip
+when their optional dependencies are absent. The web dependencies are not added
+to core installation requirements.
diff --git a/concordia/examples/astral_canticle/__init__.py b/concordia/examples/astral_canticle/__init__.py
new file mode 100644
index 000000000..e256417e5
--- /dev/null
+++ b/concordia/examples/astral_canticle/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A human-controlled science-fantasy text adventure."""
diff --git a/concordia/examples/astral_canticle/adventure.py b/concordia/examples/astral_canticle/adventure.py
new file mode 100644
index 000000000..8f609aac4
--- /dev/null
+++ b/concordia/examples/astral_canticle/adventure.py
@@ -0,0 +1,249 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Astral Canticle: standard prefab entities, sequential engine and logs.
+
+This demonstrates human input in the Auric Vesper setting. The lean
+composition uses SwitchAct directly with standard memory/context components so
+local models spend their time telling the story, not selecting a turn order.
+"""
+
+import pathlib
+from typing import Any
+
+from concordia.associative_memory import basic_associative_memory
+from concordia.components.agent import constant
+from concordia.components.agent import human_act_component
+from concordia.components.game_master import next_acting
+from concordia.components.game_master import switch_act
+from concordia.environment.engines import sequential
+from concordia.prefabs.entity import basic
+from concordia.prefabs.entity import minimal
+from concordia.typing import entity as entity_lib
+from concordia.utils import structured_logging
+import numpy as np
+
+PLAYER = 'Ilyra Venn'
+GM = 'Auric Vesper'
+PREMISE = """The heliostat-city Auric Vesper orbits a sleeping moon. Its ancient
+star-loom has begun tearing the veil into the mythic Luminous Deep. You are Ilyra
+Venn, an astromancer-engineer. Sable-9, a reliquary automaton, protects the Umbral
+pilgrims; Thorn of Io, a mycelial knight, tends the city's living machinery.
+
+You stand in the Star-Loom Chamber. A brass tuning fork hangs beside a cracked
+resonance cradle. Three pale threads run into the loom; the silver thread has
+slipped loose. Your satchel holds a prism-lantern and an insulated star-spanner.
+A warning bell tolls once. Sable-9 waits by the pilgrim lift. Thorn watches the
+roots pressing through the deck. There is time to investigate before committing
+to a repair. Your first aim: discover what is pulling the loom out of tune."""
+LOCATIONS = """The Star-Loom Chamber connects west to the Reliquary Nave, east
+to the Mycelial Gardens, and down to the Pilgrim Deck. The Nave and Gardens each
+open onto the outer Heliostat Walk. Return paths remain open unless an observed
+event changes them. The Canticle Clock begins at Cycle 1, Verse 1, Pulse 0,
+Tide Ember. Each Verse has 13 Pulses, each Cycle 8 Verses; Verses 1–2 are Ember,
+3–4 Glass, 5–6 Bloom, 7–8 Umbral. Time only moves forward."""
+RULES = """Run a coherent, intimate science-fantasy text adventure. Keep the
+established map, objects, inventory and consequences consistent. Accept natural
+language and classic commands: LOOK, EXAMINE object, TAKE object, INVENTORY,
+WEST/EAST/UP/DOWN, TALK TO name, USE object ON target, WAIT. Interpret commands
+as attempts, not guaranteed success. LOOK and INVENTORY reveal known facts;
+never punish inspection. Give specific feedback when an action is impossible.
+Reveal clues before danger. There are several ways to stabilize the star-loom;
+never require an exact magic phrase. NPCs act with their own goals. Do not choose
+Ilyra's actions, thoughts or dialogue. Do not resolve the whole adventure in one
+turn. State only observable consequences, no analysis, option menus or meta text.
+Use brief vivid paragraphs, concrete objects and clear exits. Keep each response
+under 120 words. Preserve character names. Track carried items and clock in the
+fiction; do not invent a successful repair until the actions justify one."""
+
+
+def build_cast(model, reader, *, role='player', player_prefab='minimal'):
+ """Build the cast, selecting Ilyra's standard prefab and only replacing act.
+
+ With ``basic``, Ilyra retains the library prefab's exact context configuration
+ and LLM perception behavior. NPC and GM composition does not change.
+ """
+ if player_prefab not in ('minimal', 'basic'):
+ raise ValueError(f'Unknown player prefab: {player_prefab}')
+
+ def build(
+ name,
+ instructions,
+ goal='',
+ act_component=None,
+ extra=None,
+ prefab='minimal',
+ human_controlled=False,
+ ):
+ memory_bank = basic_associative_memory.AssociativeMemoryBank(
+ sentence_embedder=lambda _: np.ones(8)
+ )
+ factory = (
+ (
+ lambda order: human_act_component.HumanActComponent(
+ reader, component_order=order
+ )
+ )
+ if human_controlled
+ else None
+ )
+ if prefab == 'basic':
+ return basic.Entity(params={'name': name, 'goal': goal}).build(
+ model=model,
+ memory_bank=memory_bank,
+ act_component=act_component,
+ act_component_factory=factory,
+ )
+ params: dict[str, Any] = {
+ 'name': name,
+ 'custom_instructions': instructions,
+ 'goal': goal,
+ 'randomize_choices': False,
+ 'extra_components': extra or {},
+ }
+ return minimal.Entity(params=params).build(
+ model=model,
+ memory_bank=memory_bank,
+ act_component=act_component,
+ act_component_factory=factory,
+ )
+
+ players = [
+ build(
+ PLAYER,
+ 'You are Ilyra Venn. ' + RULES,
+ 'Repair the star-loom without sacrificing the sleeping moon.',
+ prefab=player_prefab,
+ human_controlled=role == 'player',
+ ),
+ build(
+ 'Sable-9',
+ 'You are Sable-9, a precise but compassionate automaton. '
+ 'Act briefly in the first person. Do not control anyone else.',
+ 'Protect the pilgrims and help Ilyra investigate the resonance.',
+ ),
+ build(
+ 'Thorn of Io',
+ 'You are Thorn of Io, a patient mycelial knight. '
+ 'Act briefly in the first person. Do not control anyone else.',
+ 'Keep Auric Vesper alive and the Umbral pilgrims free.',
+ ),
+ ]
+ names = [player.name for player in players]
+ extra = {}
+ if role == 'player':
+ extra = {
+ switch_act.DEFAULT_NEXT_ACTING_COMPONENT_KEY: (
+ next_acting.NextActingInFixedOrder(sequence=names)
+ ),
+ switch_act.DEFAULT_NEXT_ACTION_SPEC_COMPONENT_KEY: (
+ next_acting.FixedActionSpec(
+ entity_lib.free_action_spec(
+ call_to_action='What do you do next, {name}?'
+ )
+ )
+ ),
+ switch_act.DEFAULT_TERMINATE_COMPONENT_KEY: constant.Constant(
+ 'No', pre_act_label=''
+ ),
+ }
+ gm = build(
+ GM,
+ RULES + '\n' + LOCATIONS,
+ act_component=(
+ None
+ if role == 'gm'
+ else switch_act.SwitchAct(model=model, entity_names=names)
+ ),
+ extra=extra,
+ human_controlled=role == 'gm',
+ )
+ for player in players:
+ player.observe(PREMISE + '\n' + LOCATIONS)
+ return players, gm
+
+
+def play(
+ model,
+ session,
+ output: pathlib.Path,
+ *,
+ role='player',
+ max_steps=30,
+ player_prefab='minimal',
+):
+ """Run one adventure; persist standard structured logs after every step."""
+ output.mkdir(parents=True, exist_ok=True)
+ players, gm = build_cast(
+ model, session, role=role, player_prefab=player_prefab
+ )
+ engine = sequential.Sequential(
+ call_to_make_observation=(
+ 'Describe only what {name} now perceives and the consequences they '
+ 'can see. Include their current location, known exits and carried '
+ 'items when useful. No hidden thoughts or plans of others. At most '
+ '120 words; clear short paragraphs, no analysis.'
+ ),
+ call_to_resolve=(
+ 'Resolve the latest attempted action only. What visibly changes? '
+ 'Respect the map, inventory and previous events. Be concrete and '
+ 'brief: at most 120 words. Do not act for another character.'
+ ),
+ )
+ raw_log = []
+
+ def save():
+ log = structured_logging.SimulationLog.from_raw_log(raw_log)
+ log.attach_memories(
+ entity_memories={
+ p.name: p.get_component('__memory__').get_all_memories_as_text()
+ for p in players
+ },
+ game_master_memories=gm.get_component(
+ '__memory__'
+ ).get_all_memories_as_text(),
+ )
+ for name, value in (
+ ('simulation.json', log.to_json()),
+ (
+ 'log.html',
+ log.to_html(title='The Astral Canticle'),
+ ),
+ ):
+ temporary = output / (name + '.tmp')
+ temporary.write_text(value, encoding='utf-8')
+ temporary.replace(output / name)
+
+ def step_done(step):
+ save()
+ session.progress(step.step, step.acting_entity)
+
+ try:
+ engine.run_loop(
+ game_masters=[gm],
+ entities=players,
+ premise=PREMISE,
+ max_steps=max_steps,
+ log=raw_log,
+ step_callback=step_done,
+ )
+ if role == 'player':
+ # The last resolved action deserves a player-visible conclusion even
+ # when there will be no next action prompt. Ask the standard GM API.
+ observation = engine.make_observation(gm, players[0])
+ players[0].observe(observation)
+ session.add_observation(observation)
+ finally:
+ save()
+ session.finish('This chapter is complete. Your journal is ready to download.')
diff --git a/concordia/examples/astral_canticle/adventure_test.py b/concordia/examples/astral_canticle/adventure_test.py
new file mode 100644
index 000000000..b4b860a0d
--- /dev/null
+++ b/concordia/examples/astral_canticle/adventure_test.py
@@ -0,0 +1,163 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Human/default policy isolation and end-to-end standard-engine unit checks."""
+
+import json
+from unittest import mock
+
+from concordia.components.agent import concat_act_component
+from concordia.components.agent import human_act_component
+from concordia.examples.astral_canticle import adventure
+from concordia.language_model import no_language_model
+from concordia.typing import entity as entity_lib
+import pytest
+
+
+def test_human_prefab_has_no_model_calls_and_npcs_keep_llm_policy():
+ model = mock.Mock(wraps=no_language_model.NoLanguageModel())
+ reader = mock.Mock(return_value='Examine the tuning fork')
+ players, gm = adventure.build_cast(model, reader)
+ assert isinstance(
+ players[0].get_act_component(), human_act_component.HumanActComponent
+ )
+ assert all(
+ isinstance(p.get_act_component(), concat_act_component.ConcatActComponent)
+ for p in players[1:]
+ )
+ result = players[0].act(
+ entity_lib.free_action_spec(call_to_action='Your move, {name}')
+ )
+ assert result == 'Examine the tuning fork'
+ assert (
+ 'resonance cradle' in reader.call_args.args[0].contexts['__observation__']
+ )
+ model.sample_text.assert_not_called()
+ model.sample_choice.assert_not_called()
+ players[1].act()
+ model.sample_text.assert_called_once()
+ assert gm.name == adventure.GM
+
+
+def test_human_gm_uses_same_component_without_llm_context_calls():
+ model = mock.Mock(wraps=no_language_model.NoLanguageModel())
+ reader = mock.Mock(return_value='No')
+ players, gm = adventure.build_cast(model, reader, role='gm')
+ assert all(
+ isinstance(p.get_act_component(), concat_act_component.ConcatActComponent)
+ for p in players
+ )
+ assert (
+ gm.act(
+ entity_lib.ActionSpec(
+ call_to_action='Finished?',
+ output_type=entity_lib.OutputType.TERMINATE,
+ options=('Yes', 'No'),
+ )
+ )
+ == 'No'
+ )
+ model.sample_text.assert_not_called()
+ model.sample_choice.assert_not_called()
+
+
+@pytest.mark.parametrize('player_prefab', ('minimal', 'basic'))
+def test_three_turns_use_standard_engine_and_produce_standard_log(
+ tmp_path, player_prefab
+):
+ model = mock.Mock(wraps=no_language_model.NoLanguageModel())
+ model.sample_text.return_value = (
+ 'The resonance cradle glows. The west exit remains open.'
+ )
+ session = mock.Mock(return_value='Examine the resonance cradle')
+ adventure.play(
+ model, session, tmp_path, max_steps=3, player_prefab=player_prefab
+ )
+ assert session.call_count == 1 # NPCs never reach the human adapter.
+ assert session.progress.call_count == 3
+ assert [c.args[1] for c in session.progress.call_args_list] == [
+ 'Ilyra Venn',
+ 'Sable-9',
+ 'Thorn of Io',
+ ]
+ session.finish.assert_called_once()
+ log = json.loads((tmp_path / 'simulation.json').read_text())
+ assert log
+ assert '"Source": "human"' in (tmp_path / 'simulation.json').read_text()
+ assert (tmp_path / 'log.html').read_text().startswith('')
+
+
+def test_basic_selection_changes_only_ilyra_and_preserves_context_calls():
+ model = mock.Mock(wraps=no_language_model.NoLanguageModel())
+ model.sample_text.return_value = 'examining the cradle.'
+ reader = mock.Mock(return_value='LOOK')
+ players, gm = adventure.build_cast(model, reader, player_prefab='basic')
+ original_players, original_gm = adventure.build_cast(model, reader)
+ for selected, original in zip(
+ [*players[1:], gm], [*original_players[1:], original_gm]
+ ):
+ assert type(selected.get_act_component()) is type(
+ original.get_act_component()
+ )
+ components = selected.get_all_context_components()
+ originals = original.get_all_context_components()
+ assert list(components) == list(originals)
+ for key, component in components.items():
+ assert type(component) is type(originals[key])
+ assert component.get_state() == originals[key].get_state()
+ assert players[0].act() == 'LOOK'
+ assert model.sample_text.call_count == 3 # Perceptions, never an LLM action.
+ assert set(reader.call_args.args[0].contexts) == {
+ 'Instructions',
+ 'Observation',
+ 'SelfPerception',
+ 'SituationPerception',
+ 'PersonBySituation',
+ '__observation__',
+ '__memory__',
+ 'Goal',
+ }
+ assert (
+ players[0]
+ .get_component('SituationPerception')
+ .get_state()['num_memories_to_retrieve']
+ == 25
+ )
+ assert (
+ players[0].get_component('__observation__').get_state()['history_length']
+ == 1_000_000
+ )
+
+
+def test_basic_selection_keeps_automated_player_when_human_is_gm():
+ model = mock.Mock(wraps=no_language_model.NoLanguageModel())
+ model.sample_text.return_value = 'examining the cradle.'
+ reader = mock.Mock(return_value='No')
+ players, gm = adventure.build_cast(
+ model, reader, role='gm', player_prefab='basic'
+ )
+ assert isinstance(
+ gm.get_act_component(), human_act_component.HumanActComponent
+ )
+ assert isinstance(
+ players[0].get_act_component(), concat_act_component.ConcatActComponent
+ )
+ players[0].act()
+ assert model.sample_text.call_count == 4
+ reader.assert_not_called()
+
+
+def test_unknown_player_prefab_is_rejected():
+ with pytest.raises(ValueError, match='Unknown player prefab'):
+ adventure.build_cast(None, None, player_prefab='typo')
diff --git a/concordia/examples/astral_canticle/cli_test.py b/concordia/examples/astral_canticle/cli_test.py
new file mode 100644
index 000000000..cc8ba852b
--- /dev/null
+++ b/concordia/examples/astral_canticle/cli_test.py
@@ -0,0 +1,74 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""CLI prefab selection reaches the runner without starting a simulation."""
+
+from unittest import mock
+
+from concordia.examples.astral_canticle import adventure
+from concordia.examples.astral_canticle import terminal
+from concordia.typing import entity as entity_lib
+from concordia.typing import human_input
+import pytest
+
+
+@pytest.mark.parametrize(
+ 'args, expected', [([], 'minimal'), (['--player-prefab', 'basic'], 'basic')]
+)
+@pytest.mark.parametrize('transport', ['terminal', 'web'])
+def test_cli_passes_prefab_to_runner(monkeypatch, args, expected, transport):
+ if transport == 'web':
+ pytest.importorskip('fastapi')
+ from concordia.examples.astral_canticle import web # pylint: disable=g-import-not-at-top
+
+ module = web
+ else:
+ module = terminal
+ monkeypatch.setattr('sys.argv', [transport, *args])
+ with mock.patch.object(adventure, 'play') as play, mock.patch.object(
+ module.ollama_model, 'OllamaLanguageModel'
+ ):
+ if transport == 'web':
+ # Invoke only the captured runner with play mocked out; no server, model
+ # inference or simulation is launched by this check.
+ with mock.patch.object(module, 'create_app') as create, mock.patch(
+ 'concordia.examples.astral_canticle.web.uvicorn.run'
+ ) as serve:
+ module.main()
+ create.call_args.kwargs['runner']()
+ assert serve.call_args.kwargs['host'] == '127.0.0.1'
+ else:
+ module.main()
+ play.assert_called_once()
+ assert play.call_args.kwargs['player_prefab'] == expected
+ assert play.call_args.kwargs['role'] == 'player'
+
+
+def test_terminal_prints_preassembled_string_without_dict_headings(
+ capsys, monkeypatch
+):
+ context = (
+ '\nGoal: Repair the loom.\n\nSelf: A keeper.\nSituation: A cracked'
+ ' cradle.\nIntent: Inspect.'
+ )
+ request = human_input.HumanInputRequest(
+ request_id='terminal',
+ entity_name='Ilyra',
+ action_spec=entity_lib.free_action_spec(call_to_action='Your move?'),
+ contexts={'not-a-display-heading': 'must not be reconstructed'},
+ context=context,
+ )
+ monkeypatch.setattr('builtins.input', lambda prompt: 'LOOK')
+ assert terminal.TerminalInput()(request) == 'LOOK'
+ assert capsys.readouterr().out == context + '\n\nIlyra: Your move?\n'
diff --git a/concordia/examples/astral_canticle/human_io.py b/concordia/examples/astral_canticle/human_io.py
new file mode 100644
index 000000000..6e362d91a
--- /dev/null
+++ b/concordia/examples/astral_canticle/human_io.py
@@ -0,0 +1,161 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""In-process, reconnectable transport adapter; no simulation logic lives here."""
+
+from collections.abc import Callable
+import threading
+
+from concordia.components.agent import human_act_component
+from concordia.typing import human_input
+
+
+class InputClosed(Exception):
+ """The controller has stopped waiting for input."""
+
+
+class StaleRequest(ValueError):
+ """A response targets an old or already answered prompt."""
+
+
+class HumanSession:
+ """One controller's inbox, not a global queue or a multiplayer session.
+
+ Closing a browser does not close this inbox. Requests survive reconnects while
+ the process runs. A new process always has new request IDs; no action is replayed.
+ """
+
+ def __init__(
+ self,
+ *,
+ role: str = 'player',
+ on_request: Callable[[], None] | None = None,
+ initial_status: str = 'The star-loom is waking…',
+ ):
+ self.role = role
+ self._on_request = on_request
+ self._condition = threading.Condition()
+ self._pending: human_input.HumanInputRequest | None = None
+ self._response: str | None = None
+ self._accepted: tuple[str, str] | None = None
+ self._closed = False
+ self._status = initial_status
+ self._entries: list[dict[str, str]] = []
+ self._observations_seen = 0
+ self._revision = 0
+ self._step = 0
+
+ def __call__(self, request: human_input.HumanInputRequest) -> str:
+ with self._condition:
+ if self._closed:
+ raise InputClosed()
+ if self._pending is not None:
+ raise RuntimeError('This controller already has a pending request.')
+ self._pending = request
+ self._response = None
+ self._status = (
+ 'Your move' if self.role == 'player' else 'The world awaits you'
+ )
+ self._revision += 1
+ # The standard observation component orders memories chronologically.
+ # Only this controlled entity's observations enter the player transcript.
+ observations = request.contexts.get('__observation__', '')
+ parts = observations.split('[observation] ')[1:]
+ for part in parts[self._observations_seen :]:
+ self._entries.append({'kind': 'story', 'text': part.strip()})
+ self._observations_seen = len(parts)
+ self._condition.notify_all()
+ if self._on_request is not None:
+ self._on_request()
+ with self._condition:
+ self._condition.wait_for(
+ lambda: self._response is not None or self._closed
+ )
+ if self._closed:
+ self._pending = None
+ raise InputClosed()
+ response = self._response
+ assert response is not None # Guaranteed by wait_for unless closed.
+ self._pending = None
+ self._response = None
+ return response
+
+ def submit(self, request_id: str, response: str) -> bool:
+ """Validate before wakeup; retries of the same accepted POST are idempotent."""
+ with self._condition:
+ if self._accepted == (request_id, response):
+ return False
+ if (
+ self._closed
+ or self._pending is None
+ or self._pending.request_id != request_id
+ or self._response is not None
+ ):
+ raise StaleRequest(
+ 'That turn has already moved on. Your draft is kept.'
+ )
+ human_act_component.validate_response(response, self._pending.action_spec)
+ self._accepted = (request_id, response)
+ self._response = response
+ self._entries.append({'kind': 'action', 'text': response})
+ self._status = 'Your action is unfolding…'
+ self._revision += 1
+ self._condition.notify_all()
+ return True
+
+ def add_observation(self, observation: str) -> None:
+ """Publish a final observation addressed to this controller's entity."""
+ with self._condition:
+ if observation.strip():
+ self._entries.append({'kind': 'story', 'text': observation})
+ self._revision += 1
+
+ def progress(self, step: int, actor: str) -> None:
+ with self._condition:
+ self._step = step
+ self._status = f'{actor} has acted. The story continues…'
+ self._revision += 1
+
+ def finish(self, message: str) -> None:
+ with self._condition:
+ self._status = message
+ self._closed = True
+ self._pending = None
+ self._revision += 1
+ self._condition.notify_all()
+
+ def snapshot(self) -> dict:
+ """Return only the controller's view, never any other entity's private context."""
+ with self._condition:
+ pending = None
+ if self._pending is not None and self._response is None:
+ request = self._pending
+ pending = {
+ 'id': request.request_id,
+ 'entity': request.entity_name,
+ 'prompt': request.action_spec.call_to_action,
+ 'type': request.action_spec.output_type.value,
+ 'options': list(request.action_spec.options),
+ 'error': request.error,
+ 'context': request.context,
+ }
+ return {
+ 'revision': self._revision,
+ 'role': self.role,
+ 'status': self._status,
+ 'step': self._step,
+ 'finished': self._closed,
+ 'pending': pending,
+ 'entries': [dict(entry) for entry in self._entries],
+ }
diff --git a/concordia/examples/astral_canticle/requirements-test.txt b/concordia/examples/astral_canticle/requirements-test.txt
new file mode 100644
index 000000000..3072e2549
--- /dev/null
+++ b/concordia/examples/astral_canticle/requirements-test.txt
@@ -0,0 +1,3 @@
+-r requirements.txt
+httpx>=0.27,<1
+playwright>=1.50,<2
diff --git a/concordia/examples/astral_canticle/requirements.txt b/concordia/examples/astral_canticle/requirements.txt
new file mode 100644
index 000000000..2ada80d43
--- /dev/null
+++ b/concordia/examples/astral_canticle/requirements.txt
@@ -0,0 +1,3 @@
+# Optional web dependencies; never installed by core Concordia.
+fastapi>=0.115,<1
+uvicorn>=0.30,<1
diff --git a/concordia/examples/astral_canticle/static/app.js b/concordia/examples/astral_canticle/static/app.js
new file mode 100644
index 000000000..b95f0410a
--- /dev/null
+++ b/concordia/examples/astral_canticle/static/app.js
@@ -0,0 +1,237 @@
+"use strict";
+const $ = (id) => document.getElementById(id);
+let current = null,
+ busy = false,
+ online = false,
+ rendered = 0,
+ revision = -1;
+let lastRequest = null;
+const storage = {
+ get(k) {
+ try {
+ return localStorage.getItem(k);
+ } catch {
+ return null;
+ }
+ },
+ set(k, v) {
+ try {
+ localStorage.setItem(k, v);
+ } catch {}
+ },
+ remove(k) {
+ try {
+ localStorage.removeItem(k);
+ } catch {}
+ },
+};
+const draftKey = "astral-canticle-draft:" + location.pathname;
+function saveDraft() {
+ storage.set(
+ draftKey,
+ JSON.stringify({ id: current?.id, value: $("command").value }),
+ );
+}
+function setDraft(value) {
+ $("command").value = value;
+ saveDraft();
+ enable();
+}
+function enable() {
+ $("send").disabled =
+ busy || !online || !current || !$("command").value.trim();
+}
+function showError(text) {
+ $("feedback").textContent = text;
+}
+function render(state) {
+ $("status").textContent = state.status;
+ if (state.revision === revision) {
+ enable();
+ return;
+ }
+ revision = state.revision;
+ const nearEnd =
+ window.innerHeight + window.scrollY >= document.body.scrollHeight - 240;
+ if (state.entries.length < rendered) {
+ $("story").replaceChildren();
+ rendered = 0;
+ }
+ for (const entry of state.entries.slice(rendered)) {
+ const p = document.createElement("div");
+ p.className = entry.kind === "action" ? "player-action" : "passage";
+ if (entry.kind === "action") {
+ const label = document.createElement("small");
+ label.textContent = "YOUR ACTION";
+ p.append(label);
+ }
+ p.append(document.createTextNode(entry.text));
+ $("story").append(p);
+ }
+ const added = rendered !== state.entries.length;
+ rendered = state.entries.length;
+ $("status").textContent = state.status;
+ $("ending").hidden = !state.finished;
+ current = state.pending;
+ $("prompt").textContent =
+ current?.prompt ||
+ (state.finished
+ ? "Chapter complete"
+ : "The world is responding. You can draft your next action.");
+ $("command").placeholder =
+ current?.type === "float"
+ ? "Enter a finite number…"
+ : "What do you try next?";
+ $("command").inputMode = current?.type === "float" ? "decimal" : "text";
+ if (state.role === "gm") {
+ $("role-label").textContent = "YOU ARE THE";
+ $("character-name").textContent = "Game master";
+ $("character-detail").textContent =
+ "Shape observations, choose actors, resolve their attempts";
+ }
+ $("entity-context").hidden = !current?.context;
+ $("context").textContent = current?.context || "";
+ if (current?.id !== lastRequest) {
+ lastRequest = current?.id;
+ $("choices").replaceChildren();
+ $("choices").hidden = !current?.options?.length;
+ for (const option of current?.options || []) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = option;
+ button.onclick = () => {
+ setDraft(option);
+ $("command").focus();
+ };
+ $("choices").append(button);
+ }
+ $("spec-builder").hidden = current?.type !== "next_action_spec";
+ if (current?.error) showError(current.error);
+ }
+ enable();
+ if (added && rendered > 1) {
+ if (nearEnd)
+ $("waiting").scrollIntoView({ block: "end", behavior: "smooth" });
+ else $("new-entry").hidden = false;
+ }
+}
+async function poll() {
+ try {
+ const response = await fetch("api/state", {
+ cache: "no-store",
+ signal: AbortSignal.timeout(12000),
+ });
+ if (!response.ok) throw new Error("unavailable");
+ const state = await response.json();
+ online = true;
+ $("connection").textContent = "CONNECTED";
+ render(state);
+ } catch {
+ online = false;
+ $("connection").textContent = "RECONNECTING";
+ $("status").textContent =
+ "Connection interrupted. Your draft is safe; reconnecting…";
+ enable();
+ } finally {
+ setTimeout(poll, document.hidden ? 4000 : 1200);
+ }
+}
+$("action-form").addEventListener("submit", async (event) => {
+ event.preventDefault();
+ if (!current || busy || !online || !$("command").value.trim()) return;
+ busy = true;
+ enable();
+ showError("");
+ const id = current.id,
+ value = $("command").value;
+ try {
+ const response = await fetch("api/action", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Astral-Client": "1" },
+ body: JSON.stringify({ request_id: id, response: value }),
+ signal: AbortSignal.timeout(15000),
+ });
+ const result = await response.json();
+ if (!response.ok)
+ throw new Error(
+ typeof result.detail === "string"
+ ? result.detail
+ : "Please check your response and try again.",
+ );
+ // A successful POST, including an idempotent retry, is the only point at
+ // which a draft is discarded. Network failures never erase human input.
+ if ($("command").value === value) {
+ $("command").value = "";
+ storage.remove(draftKey);
+ } else {
+ saveDraft();
+ }
+ current = null;
+ revision = -1;
+ $("status").textContent = "Your action is unfolding…";
+ } catch (error) {
+ showError(
+ error.name === "TimeoutError" || error instanceof TypeError
+ ? "Could not confirm submission. Your draft is kept. Retry when connected."
+ : error.message,
+ );
+ } finally {
+ busy = false;
+ enable();
+ }
+});
+$("command").addEventListener("input", () => {
+ saveDraft();
+ enable();
+});
+$("command").addEventListener("keydown", (event) => {
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
+ event.preventDefault();
+ $("action-form").requestSubmit();
+ }
+});
+document.querySelectorAll("[data-command]").forEach(
+ (button) =>
+ (button.onclick = () => {
+ setDraft(button.dataset.command);
+ $("command").focus();
+ }),
+);
+$("help-button").onclick = () => {
+ $("help").hidden = !$("help").hidden;
+ $("help-button").setAttribute("aria-expanded", String(!$("help").hidden));
+ if (!$("help").hidden) $("help").scrollIntoView({ block: "center" });
+};
+$("font").onclick = () => {
+ document.body.classList.toggle("large");
+ storage.set(
+ "astral-large-text",
+ document.body.classList.contains("large") ? "1" : "0",
+ );
+};
+$("jump").onclick = () => {
+ $("waiting").scrollIntoView({ block: "end", behavior: "smooth" });
+ $("new-entry").hidden = true;
+};
+$("build-spec").onclick = () => {
+ const type = $("spec-type").value;
+ setDraft(
+ JSON.stringify({
+ call_to_action: $("spec-prompt").value,
+ output_type: type,
+ options:
+ type === "choice"
+ ? $("spec-options")
+ .value.split("\n")
+ .filter((v) => v.trim())
+ : [],
+ }),
+ );
+};
+try {
+ const draft = JSON.parse(storage.get(draftKey));
+ if (draft?.value) $("command").value = draft.value;
+} catch {}
+if (storage.get("astral-large-text") === "1")
+ document.body.classList.add("large");
+poll();
diff --git a/concordia/examples/astral_canticle/static/index.html b/concordia/examples/astral_canticle/static/index.html
new file mode 100644
index 000000000..d4a62ea6a
--- /dev/null
+++ b/concordia/examples/astral_canticle/static/index.html
@@ -0,0 +1,168 @@
+
+
+
+ A sleeping moon. A fractured star-loom. And you, with a lantern in
+ your hand.
+
+
+ IV
+
+ YOU AREIlyra VennAstromancer · Engineer · Entirely your own mind
+
+
+
+
+
The world listens to ordinary words.
+
+ Describe one thing you try. Explore, speak, experiment. The world
+ answers; your companions make their own choices.
+
+
+
LOOK / EXAMINE cradle
+
Notice your surroundings and search for clues.
+
INVENTORY / TAKE tuning fork
+
Check or change what you carry.
+
WEST / TALK TO Sable-9
+
Follow an exit or begin a conversation.
+
USE tuning fork ON cradle
+
Try an idea. Exact phrasing is never required.
+
+
+ Closing this page is fine. Return on this device to continue the same
+ adventure while the host is running. Your draft stays here; submitted
+ actions are never repeated automatically.
+
+
+
+ CHAPTER ITHE DISSONANT THREAD✧
+
+
+
+
Your context
+
+
+
+ Connecting to Auric Vesper…
+
+
+ This chapter is yours to keep. Download your journal above.
+
+
+
+
+
+
+
+
+ CONNECTING
+
+
+
+
+
+
+
+
+
+
+
+ YOUR WORDS. YOUR CHOICE.
+
+
+
+
+
+
diff --git a/concordia/examples/astral_canticle/static/style.css b/concordia/examples/astral_canticle/static/style.css
new file mode 100644
index 000000000..3797666eb
--- /dev/null
+++ b/concordia/examples/astral_canticle/static/style.css
@@ -0,0 +1,563 @@
+:root {
+ color-scheme: dark;
+ --bg: #111923;
+ --panel: #18232d;
+ --ink: #e6e4d9;
+ --muted: #a1afb7;
+ --gold: #debd83;
+ --edge: #33414b;
+ --story-size: 1.125rem;
+}
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ background:
+ radial-gradient(ellipse at 85% 0, #2a303c 0, transparent 38rem), var(--bg);
+ color: var(--ink);
+ font:
+ 15px/1.6 system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+}
+button,
+a,
+input,
+textarea,
+select {
+ -webkit-tap-highlight-color: transparent;
+}
+button,
+a {
+ touch-action: manipulation;
+}
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+button,
+a {
+ color: inherit;
+}
+button {
+ cursor: pointer;
+}
+button:disabled {
+ cursor: default;
+ opacity: 0.45;
+}
+button:focus-visible,
+a:focus-visible,
+summary:focus-visible,
+textarea:focus-visible,
+input:focus-visible,
+select:focus-visible {
+ outline: 2px solid var(--gold);
+ outline-offset: 4px;
+}
+button {
+ background: none;
+ border: 0;
+}
+a {
+ text-decoration: none;
+}
+a:hover,
+button:hover {
+ color: var(--gold);
+}
+.topbar {
+ max-width: 1100px;
+ margin: auto;
+ padding: 28px 40px;
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ align-items: center;
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ line-height: 1.3;
+ letter-spacing: 0.22em;
+ font-size: 10px;
+}
+.brand b {
+ font-size: 16px;
+ letter-spacing: 0.19em;
+ font-weight: 500;
+}
+.sigil {
+ font-size: 46px;
+ color: var(--gold);
+ font-weight: 200;
+ line-height: 1;
+}
+.topbar nav {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: #c8cfcf;
+}
+.topbar nav > * {
+ min-height: 44px;
+ padding: 12px 10px;
+ white-space: nowrap;
+}
+main {
+ max-width: 760px;
+ margin: auto;
+ padding: 28px 34px 60px;
+}
+.opening {
+ padding: 24px 0 36px;
+}
+.eyebrow {
+ font-size: 9px;
+ letter-spacing: 0.22em;
+ color: var(--gold);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.line {
+ height: 1px;
+ width: 26px;
+ background: var(--gold);
+}
+h1 {
+ font:
+ 400 clamp(2.5rem, 6vw, 3.7rem)/1.13 Georgia,
+ "Times New Roman",
+ serif;
+ letter-spacing: -0.025em;
+ margin: 24px 0 20px;
+}
+h1 em {
+ font-weight: 400;
+ color: #babbb3;
+}
+.lede {
+ color: var(--muted);
+ font-size: 14px;
+ max-width: 410px;
+}
+.character {
+ display: flex;
+ gap: 15px;
+ align-items: center;
+ margin-top: 30px;
+}
+.portrait {
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ border: 1px solid #626252;
+ border-radius: 50%;
+ width: 49px;
+ height: 49px;
+ color: var(--gold);
+ font:
+ 18px Georgia,
+ serif;
+ background: #252e32;
+}
+.character div {
+ display: grid;
+ gap: 1px;
+}
+.character small {
+ color: var(--gold);
+ letter-spacing: 0.19em;
+ font-size: 8px;
+}
+.character strong {
+ font:
+ 20px Georgia,
+ serif;
+ font-weight: 400;
+}
+.character div > span {
+ font-size: 11px;
+ color: var(--muted);
+}
+.chapter {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ border-top: 1px solid var(--edge);
+ padding: 22px 0;
+ color: var(--muted);
+ font-size: 9px;
+ letter-spacing: 0.18em;
+}
+.chapter span:first-child {
+ color: var(--gold);
+}
+.chapter span:last-child {
+ margin-left: auto;
+ color: var(--gold);
+ font-size: 17px;
+}
+.passage {
+ font:
+ var(--story-size)/1.8 Georgia,
+ "Times New Roman",
+ serif;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ animation: arrive 0.35s ease-out;
+ margin: 0 0 24px;
+}
+.player-action {
+ border-left: 2px solid var(--gold);
+ padding: 12px 18px;
+ background: linear-gradient(90deg, #252e35, transparent);
+ font:
+ 14px/1.65 system-ui,
+ sans-serif;
+ color: var(--gold);
+ margin: 30px 0;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.player-action small {
+ display: block;
+ font:
+ 8px system-ui,
+ sans-serif;
+ letter-spacing: 0.2em;
+ color: var(--muted);
+ margin-bottom: 6px;
+}
+.waiting {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+ color: var(--muted);
+ padding: 6px 0 22px;
+}
+.pulse {
+ width: 5px;
+ height: 5px;
+ background: var(--gold);
+ border-radius: 50%;
+ box-shadow: 0 0 12px #debd8370;
+ animation: pulse 2s infinite;
+}
+.composer {
+ position: sticky;
+ bottom: 0;
+ background: linear-gradient(0deg, #111923 90%, #111923ec);
+ border-top: 1px solid var(--edge);
+ padding: 16px 28px max(18px, env(safe-area-inset-bottom));
+ z-index: 2;
+}
+.composer-inner {
+ max-width: 692px;
+ margin: auto;
+}
+.turnline {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: center;
+ margin-bottom: 10px;
+ font-size: 12px;
+ color: var(--ink);
+}
+#prompt {
+ max-width: 80%;
+ line-height: 1.4;
+}
+#connection {
+ color: var(--gold);
+ font-size: 8px;
+ letter-spacing: 0.15em;
+ white-space: nowrap;
+}
+#action-form {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border: 1px solid #4d575d;
+ border-radius: 6px;
+ background: #1b2731;
+ padding: 10px 12px;
+}
+#action-form:focus-within {
+ border-color: var(--gold);
+}
+.chevron {
+ font:
+ 27px Georgia,
+ serif;
+ color: var(--gold);
+}
+#command {
+ width: 100%;
+ min-width: 0;
+ max-height: 130px;
+ resize: vertical;
+ border: 0;
+ background: none;
+ color: var(--ink);
+ font-size: 16px;
+ line-height: 1.5;
+ outline: none !important;
+ padding: 3px;
+}
+#command::placeholder {
+ color: #899ba6;
+}
+#send {
+ padding: 10px 16px;
+ min-height: 46px;
+ border-radius: 4px;
+ background: var(--gold);
+ color: #19222b;
+ white-space: nowrap;
+ font-size: 13px;
+ font-weight: 600;
+}
+#send span {
+ margin-left: 8px;
+}
+.shortcuts {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-top: 6px;
+}
+.shortcuts button {
+ font-size: 11px;
+ color: var(--muted);
+ padding: 9px 10px;
+ min-height: 44px;
+}
+.shortcuts button:first-child {
+ padding-left: 0;
+}
+.shortcuts span {
+ margin-left: auto;
+ color: #93a2ab;
+ font-size: 8px;
+ letter-spacing: 0.1em;
+}
+#feedback {
+ font-size: 12px;
+ color: #f0bcab;
+ margin: 6px 0;
+ min-height: 0;
+}
+#feedback:empty {
+ display: none;
+}
+.help {
+ background: var(--panel);
+ border: 1px solid var(--edge);
+ border-radius: 6px;
+ padding: 20px;
+ margin-bottom: 26px;
+ font-size: 13px;
+}
+.help h2 {
+ font:
+ 23px Georgia,
+ serif;
+ margin-top: 0;
+}
+.help p {
+ color: var(--muted);
+}
+dl {
+ margin: 0;
+}
+dt {
+ font-size: 12px;
+ color: var(--gold);
+ margin-top: 12px;
+}
+dd {
+ margin-left: 0;
+ color: var(--muted);
+}
+.choices {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin: 12px 0;
+}
+.choices button,
+#build-spec {
+ border: 1px solid var(--edge);
+ border-radius: 5px;
+ padding: 10px 14px;
+ min-height: 44px;
+ color: var(--gold);
+ font-size: 14px;
+}
+#spec-builder {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ margin: 12px 0;
+ max-height: 220px;
+ overflow: auto;
+}
+#spec-builder label {
+ display: grid;
+ gap: 4px;
+ font-size: 12px;
+}
+#spec-builder input,
+#spec-builder textarea,
+#spec-builder select {
+ background: var(--panel);
+ color: var(--ink);
+ border: 1px solid var(--edge);
+ padding: 7px;
+ width: 100%;
+ font-size: 16px;
+}
+#entity-context {
+ margin: 28px 0;
+ padding: 22px;
+ border: 1px solid var(--edge);
+ border-radius: 12px;
+}
+#context-title {
+ color: var(--gold);
+ font-size: 1rem;
+}
+
+#context {
+ font: inherit;
+ line-height: 1.7;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+#ending {
+ color: var(--gold);
+ font:
+ 20px Georgia,
+ serif;
+}
+#new-entry {
+ text-align: center;
+}
+#jump {
+ color: var(--gold);
+ border: 1px solid var(--edge);
+ border-radius: 20px;
+ padding: 8px 18px;
+ min-height: 44px;
+}
+[hidden] {
+ display: none !important;
+}
+body.large {
+ --story-size: 1.35rem;
+}
+@keyframes pulse {
+ 50% {
+ opacity: 0.3;
+ }
+}
+@keyframes arrive {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+@media (max-width: 600px) {
+ .topbar {
+ padding: 20px 20px 12px;
+ gap: 8px;
+ }
+ .brand {
+ gap: 8px;
+ font-size: 8px;
+ }
+ .brand b {
+ font-size: 12px;
+ }
+ .sigil {
+ font-size: 35px;
+ }
+ .topbar nav {
+ gap: 0;
+ font-size: 11px;
+ }
+ .topbar nav > * {
+ padding: 12px 8px;
+ }
+ main {
+ padding: 18px 24px 32px;
+ }
+ .opening {
+ padding: 8px 0 28px;
+ }
+ .eyebrow {
+ font-size: 8px;
+ letter-spacing: 0.12em;
+ gap: 8px;
+ }
+ h1 {
+ margin-top: 20px;
+ font-size: 2.45rem;
+ }
+ .lede {
+ font-size: 13px;
+ }
+ .chapter {
+ gap: 12px;
+ font-size: 8px;
+ letter-spacing: 0.12em;
+ }
+ .composer {
+ padding: 13px 18px max(12px, env(safe-area-inset-bottom));
+ }
+ .shortcuts span {
+ display: none;
+ }
+ .shortcuts {
+ justify-content: space-between;
+ }
+ .shortcuts button {
+ font-size: 12px;
+ }
+ #send {
+ padding: 10px 13px;
+ }
+ #send span {
+ display: none;
+ }
+ .turnline {
+ font-size: 11px;
+ }
+ .passage {
+ line-height: 1.75;
+ }
+ .character div > span {
+ font-size: 10px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation: none !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/concordia/examples/astral_canticle/terminal.py b/concordia/examples/astral_canticle/terminal.py
new file mode 100644
index 000000000..adccde4b6
--- /dev/null
+++ b/concordia/examples/astral_canticle/terminal.py
@@ -0,0 +1,77 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Terminal transport for the same adventure and HumanActComponent API."""
+
+import argparse
+import pathlib
+
+from concordia.contrib.language_models.ollama import ollama_model
+from concordia.examples.astral_canticle import adventure
+
+
+class TerminalInput:
+ """A simple synchronous reader demonstrating a non-web transport."""
+
+ def __call__(self, request):
+ print(request.context)
+ print(f'\n{request.entity_name}: {request.action_spec.call_to_action}')
+ if request.action_spec.options:
+ print('Choose exactly: ' + ' | '.join(request.action_spec.options))
+ if request.error:
+ print(request.error)
+ return input('> ')
+
+ def progress(self, step, actor):
+ print(f'\nTurn {step}: {actor} acted.')
+
+ def add_observation(self, observation):
+ print('\n' + observation)
+
+ def finish(self, message):
+ print('\n' + message)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--role', choices=('player', 'gm'), default='player')
+ parser.add_argument(
+ '--player-prefab',
+ choices=('minimal', 'basic'),
+ default='minimal',
+ help='Standard prefab for Ilyra; basic retains its LLM perceptions.',
+ )
+ parser.add_argument('--model', default='llama3.2:3b')
+ parser.add_argument('--max-steps', type=int, default=30)
+ parser.add_argument(
+ '--output', type=pathlib.Path, default=pathlib.Path('runs/terminal')
+ )
+ args = parser.parse_args()
+ if not 1 <= args.max_steps <= 300:
+ parser.error('--max-steps must be between 1 and 300')
+ try:
+ adventure.play(
+ ollama_model.OllamaLanguageModel(args.model),
+ TerminalInput(),
+ args.output,
+ role=args.role,
+ max_steps=args.max_steps,
+ player_prefab=args.player_prefab,
+ )
+ except (EOFError, KeyboardInterrupt):
+ print('\nStopped. Completed turns are saved in ' + str(args.output))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/concordia/examples/astral_canticle/ui_test.py b/concordia/examples/astral_canticle/ui_test.py
new file mode 100644
index 000000000..3672f2d31
--- /dev/null
+++ b/concordia/examples/astral_canticle/ui_test.py
@@ -0,0 +1,250 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Browser integration against the real adapter with a fake input request.
+
+These tests do not start a Concordia simulation or contact a model. Install the
+optional example test dependencies and run `python -m playwright install chromium`.
+"""
+
+import concurrent.futures
+import socket
+import threading
+import time
+from unittest import mock
+
+from concordia.examples.astral_canticle import adventure
+from concordia.examples.astral_canticle import human_io
+from concordia.language_model import no_language_model
+from concordia.typing import entity as entity_lib
+from concordia.typing import human_input
+import pytest
+
+pytest.importorskip('fastapi')
+pytest.importorskip('uvicorn')
+pytest.importorskip('playwright.sync_api')
+web = pytest.importorskip('concordia.examples.astral_canticle.web')
+playwright = pytest.importorskip('playwright.sync_api')
+expect = playwright.expect
+sync_playwright = playwright.sync_playwright
+uvicorn = pytest.importorskip('uvicorn')
+
+
+@pytest.fixture
+def serve():
+ resources = []
+
+ def start(
+ role='player', output_type=entity_lib.OutputType.FREE, human_request=None
+ ):
+ session = human_io.HumanSession(role=role)
+ pool = concurrent.futures.ThreadPoolExecutor()
+ result = pool.submit(
+ session,
+ human_request
+ or human_input.HumanInputRequest(
+ request_id='ui-request',
+ entity_name='Ilyra Venn',
+ action_spec=entity_lib.ActionSpec(
+ call_to_action='What do you do?', output_type=output_type
+ ),
+ context='My context:\nA silver door.\n',
+ contexts={
+ '__observation__': (
+ '[observation] You stand in the Star-Loom Chamber. A brass'
+ ' tuning fork hangs beside a cracked resonance cradle. The'
+ ' west exit leads to the Reliquary Nave.'
+ ' '
+ )
+ },
+ ),
+ )
+ sock = socket.socket()
+ sock.bind(('127.0.0.1', 0))
+ server = uvicorn.Server(
+ uvicorn.Config(web.create_app(session), log_level='error')
+ )
+ thread = threading.Thread(
+ target=server.run, kwargs={'sockets': [sock]}, daemon=True
+ )
+ thread.start()
+ deadline = time.monotonic() + 5
+ while not server.started and time.monotonic() < deadline:
+ time.sleep(0.01)
+ assert server.started
+ resources.append((server, thread, session, pool, sock))
+ return f'http://127.0.0.1:{sock.getsockname()[1]}/', session, result
+
+ yield start
+ for server, thread, session, pool, sock in resources:
+ session.finish('Test complete')
+ server.should_exit = True
+ thread.join(timeout=5)
+ pool.shutdown(wait=True)
+ sock.close()
+
+
+def test_mobile_draft_reload_offline_recovery_and_single_submit(
+ serve, tmp_path
+):
+ url, session, result = serve()
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ page = browser.new_page(
+ viewport={'width': 390, 'height': 844}, is_mobile=True, has_touch=True
+ )
+ errors = []
+ page.on('pageerror', lambda error: errors.append(str(error)))
+ page.goto(url)
+ expect(page.locator('#connection')).to_have_text('CONNECTED')
+ expect(page.locator('.passage')).to_contain_text('')
+ page.locator('#command').fill('Examine the resonance cradle')
+ page.reload()
+ expect(page.locator('#command')).to_have_value(
+ 'Examine the resonance cradle'
+ )
+ expect(page.locator('#send')).to_be_enabled()
+ assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
+ page.screenshot(
+ path=str(tmp_path / 'mobile.png'), full_page=True, animations='disabled'
+ )
+ page.context.set_offline(True)
+ expect(page.locator('#connection')).to_have_text(
+ 'RECONNECTING', timeout=18000
+ )
+ expect(page.locator('#send')).to_be_disabled()
+ page.context.set_offline(False)
+ expect(page.locator('#connection')).to_have_text('CONNECTED', timeout=18000)
+ expect(page.locator('#status')).to_have_text('Your move')
+ expect(page.locator('#command')).to_have_value(
+ 'Examine the resonance cradle'
+ )
+ page.locator('#send').click()
+ assert result.result(timeout=4) == 'Examine the resonance cradle'
+ expect(page.locator('.player-action')).to_contain_text(
+ 'Examine the resonance cradle'
+ )
+ page.reload()
+ expect(page.locator('#command')).to_have_value('')
+ assert (
+ len([e for e in session.snapshot()['entries'] if e['kind'] == 'action'])
+ == 1
+ )
+ assert not errors
+ browser.close()
+
+
+def test_gm_action_spec_builder_and_desktop_layout(serve, tmp_path):
+ url, _, result = serve('gm', entity_lib.OutputType.NEXT_ACTION_SPEC)
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ page = browser.new_page(viewport={'width': 1280, 'height': 900})
+ page.goto(url)
+ expect(page.locator('#spec-builder')).to_be_visible()
+ page.locator('#spec-type').select_option('choice')
+ page.locator('#spec-options').fill(' North \nWest')
+ page.locator('#build-spec').click()
+ page.screenshot(
+ path=str(tmp_path / 'desktop-gm.png'),
+ full_page=True,
+ animations='disabled',
+ )
+ page.locator('#send').click()
+ assert ' North ' in result.result(timeout=4)
+ assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
+ browser.close()
+
+
+def test_draft_edited_while_post_is_in_flight_is_not_lost(serve):
+ url, _, result = serve()
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ page = browser.new_page()
+ page.goto(url)
+ expect(page.locator('#connection')).to_have_text('CONNECTED')
+ page.locator('#command').fill('Examine the cradle')
+
+ def hold_reply(route):
+ response = route.fetch()
+ page.locator('#command').fill('Talk to Sable-9')
+ route.fulfill(response=response)
+
+ page.route('**/api/action', hold_reply)
+ page.locator('#send').click()
+ assert result.result(timeout=4) == 'Examine the cradle'
+ expect(page.locator('.player-action')).to_contain_text('Examine the cradle')
+ page.reload()
+ expect(page.locator('#command')).to_have_value('Talk to Sable-9')
+ expect(page.locator('#send')).to_be_disabled()
+ browser.close()
+
+
+@pytest.mark.parametrize('role', ['player', 'gm'])
+def test_complete_basic_context_visible_verbatim_in_prefab_order(
+ serve, tmp_path, role
+):
+ model = mock.Mock(wraps=no_language_model.NoLanguageModel())
+ model.sample_text.return_value = (
+ 'A basic perception answer.\nIts second line.'
+ )
+ reader = mock.Mock(return_value='LOOK')
+ players, gm = adventure.build_cast(model, reader, player_prefab='basic')
+ players[1].observe('NPC-PRIVATE-SENTINEL')
+ gm.observe('GM-PRIVATE-SENTINEL')
+ players[0].act() # Unit fixture: mock model/input, no simulation launch.
+ request = reader.call_args.args[0]
+ url, session, result = serve(role=role, human_request=request)
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ page = browser.new_page(
+ viewport={'width': 390, 'height': 844}, is_mobile=True, has_touch=True
+ )
+ errors = []
+ page.on('pageerror', lambda error: errors.append(str(error)))
+ page.goto(url)
+ expect(page.locator('#entity-context')).to_be_visible()
+ displayed = page.locator('#context').text_content()
+ assert displayed == request.context
+ # These are the actual basic prefab labels, in its Concat order (not the
+ # dependency/inference execution order). No new dict-key headings are added.
+ labels = [
+ 'Question: What kind of person is Ilyra Venn?',
+ 'Question: What situation is Ilyra Venn in right now?',
+ (
+ 'Question: What would a person like Ilyra Venn do in a situation'
+ ' like this?'
+ ),
+ ]
+ offsets = [displayed.index(label) for label in labels]
+ assert offsets == sorted(offsets)
+ assert displayed.count('A basic perception answer.\nIts second line.') == 3
+ assert 'NPC-PRIVATE-SENTINEL' not in displayed
+ assert 'GM-PRIVATE-SENTINEL' not in displayed
+ assert (
+ page.locator('#context').evaluate(
+ 'el => getComputedStyle(el).whiteSpace'
+ )
+ == 'pre-wrap'
+ )
+ assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
+ page.reload()
+ expect(page.locator('#entity-context')).to_be_visible()
+ assert page.locator('#context').text_content() == request.context
+ page.screenshot(
+ path=str(tmp_path / f'basic-context-{role}.png'), full_page=True
+ )
+ assert not errors
+ assert not result.done()
+ assert session.snapshot()['pending']['context'] == request.context
+ browser.close()
diff --git a/concordia/examples/astral_canticle/web.py b/concordia/examples/astral_canticle/web.py
new file mode 100644
index 000000000..00cb4786a
--- /dev/null
+++ b/concordia/examples/astral_canticle/web.py
@@ -0,0 +1,224 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Optional single-controller web transport. Importing never starts a game."""
+
+import argparse
+import contextlib
+import logging
+import pathlib
+import threading
+
+from concordia.contrib.language_models.ollama import ollama_model
+from concordia.examples.astral_canticle import adventure
+from concordia.examples.astral_canticle import human_io
+from fastapi import FastAPI
+from fastapi import HTTPException
+from fastapi import Request
+from fastapi.responses import FileResponse
+from fastapi.responses import JSONResponse
+from fastapi.responses import Response
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+from pydantic import Field
+from starlette.middleware.trustedhost import TrustedHostMiddleware
+import uvicorn
+
+_LOG = logging.getLogger(__name__)
+_STATIC = pathlib.Path(__file__).with_name('static')
+
+
+class Submission(BaseModel):
+ request_id: str = Field(min_length=1, max_length=128)
+ response: str = Field(min_length=1, max_length=8000)
+
+
+def create_app(
+ session: human_io.HumanSession,
+ *,
+ runner=None,
+ allowed_hosts=('127.0.0.1', 'localhost'),
+ root_path='',
+) -> FastAPI:
+ """Create a browser adapter; the optional runner owns one adventure process.
+
+ There are no start/reset/lobby endpoints. Reloading cannot launch a second
+ simulation. Bind to loopback and use a private authenticated reverse proxy
+ such as Tailscale Serve. All permitted tailnet users share this one controller.
+ """
+
+ @contextlib.asynccontextmanager
+ async def lifespan(app):
+ del app
+ thread = None
+ if runner is not None:
+
+ def run():
+ try:
+ runner()
+ except human_io.InputClosed:
+ pass
+ except Exception: # pylint: disable=broad-exception-caught
+ _LOG.exception('Adventure stopped')
+ session.finish(
+ 'The story paused unexpectedly. Your journal is safe; '
+ 'ask the host to check the adventure log.'
+ )
+
+ thread = threading.Thread(
+ target=run, name='concordia-adventure', daemon=True
+ )
+ thread.start()
+ yield
+ session.finish('The adventure host has stopped. Your journal is saved.')
+ if thread is not None:
+ thread.join(timeout=1)
+
+ app = FastAPI(
+ lifespan=lifespan,
+ docs_url=None,
+ redoc_url=None,
+ openapi_url=None,
+ root_path=root_path,
+ )
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(allowed_hosts))
+
+ @app.middleware('http')
+ async def browser_boundary(request: Request, call_next):
+ # ASGI root_path denotes the mount location. Starlette's StaticFiles needs
+ # scope.path to include it, even when a reverse proxy stripped the prefix.
+ # Normalize once so both stripping and preserving proxies work.
+ path = request.scope['path']
+ if root_path and path != root_path and not path.startswith(root_path + '/'):
+ request.scope['path'] = root_path + path
+ request.scope['raw_path'] = request.scope['path'].encode('utf-8')
+ # The browser supplies Origin. Match host (including port), and require JSON
+ # plus a custom header: a foreign page cannot issue a simple form POST.
+ if request.method == 'POST':
+ origin = request.headers.get('origin', '')
+ host = request.headers.get('host', '')
+ if origin not in (f'http://{host}', f'https://{host}'):
+ return JSONResponse(
+ {'detail': 'Same-origin requests only.'}, status_code=403
+ )
+ if request.headers.get('x-astral-client') != '1':
+ return JSONResponse(
+ {'detail': 'Missing client header.'}, status_code=403
+ )
+ if (
+ request.headers.get('content-type', '').split(';')[0]
+ != 'application/json'
+ ):
+ return JSONResponse({'detail': 'JSON required.'}, status_code=415)
+ try:
+ if int(request.headers.get('content-length', '0')) > 40000:
+ return Response(status_code=413)
+ except ValueError:
+ return Response(status_code=400)
+ # Enforce actual size too, including chunked requests.
+ body = await request.body()
+ if len(body) > 40000:
+ return Response(status_code=413)
+ response = await call_next(request)
+ response.headers['Cache-Control'] = 'no-store'
+ response.headers['X-Content-Type-Options'] = 'nosniff'
+ response.headers['Referrer-Policy'] = 'no-referrer'
+ response.headers['Content-Security-Policy'] = (
+ "default-src 'self'; script-src 'self'; style-src 'self'; "
+ "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; "
+ "base-uri 'none'; form-action 'self'"
+ )
+ return response
+
+ @app.get('/')
+ def index():
+ return FileResponse(_STATIC / 'index.html')
+
+ @app.get('/api/state')
+ def state():
+ return session.snapshot()
+
+ @app.post('/api/action')
+ def submit(body: Submission):
+ try:
+ accepted = session.submit(body.request_id, body.response)
+ except human_io.StaleRequest as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ return {'accepted': accepted}
+
+ @app.get('/api/journal')
+ def journal():
+ snapshot = session.snapshot()
+ text = 'THE ASTRAL CANTICLE\n\n' + '\n\n'.join(
+ ('> ' if entry['kind'] == 'action' else '') + entry['text']
+ for entry in snapshot['entries']
+ )
+ return Response(
+ text,
+ media_type='text/plain',
+ headers={
+ 'Content-Disposition': (
+ 'attachment; filename="astral-canticle-journal.txt"'
+ )
+ },
+ )
+
+ app.mount('/static', StaticFiles(directory=_STATIC), name='static')
+ return app
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--model', default='llama3.2:3b')
+ parser.add_argument('--role', choices=('player', 'gm'), default='player')
+ parser.add_argument(
+ '--player-prefab',
+ choices=('minimal', 'basic'),
+ default='minimal',
+ help='Standard prefab for Ilyra; basic retains its LLM perceptions.',
+ )
+ parser.add_argument('--port', type=int, default=8767)
+ parser.add_argument('--allowed-host', action='append', default=[])
+ parser.add_argument('--root-path', default='')
+ parser.add_argument('--max-steps', type=int, default=30)
+ parser.add_argument(
+ '--output', type=pathlib.Path, default=pathlib.Path('runs/human')
+ )
+ args = parser.parse_args()
+ if not 1 <= args.max_steps <= 300:
+ parser.error('--max-steps must be between 1 and 300')
+ if not 1 <= args.port <= 65535:
+ parser.error('--port must be between 1 and 65535')
+ session = human_io.HumanSession(role=args.role)
+ model = ollama_model.OllamaLanguageModel(model_name=args.model)
+ app = create_app(
+ session,
+ runner=lambda: adventure.play(
+ model,
+ session,
+ args.output,
+ role=args.role,
+ max_steps=args.max_steps,
+ player_prefab=args.player_prefab,
+ ),
+ allowed_hosts=['127.0.0.1', 'localhost', *args.allowed_host],
+ root_path=args.root_path,
+ )
+ uvicorn.run(app, host='127.0.0.1', port=args.port)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/concordia/examples/astral_canticle/web_test.py b/concordia/examples/astral_canticle/web_test.py
new file mode 100644
index 000000000..6912c1d5b
--- /dev/null
+++ b/concordia/examples/astral_canticle/web_test.py
@@ -0,0 +1,212 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Real transport requests, no LLM calls or simulation launches."""
+
+import concurrent.futures
+import time
+
+from concordia.examples.astral_canticle import human_io
+from concordia.typing import entity as entity_lib
+from concordia.typing import human_input
+import pytest
+
+pytest.importorskip('fastapi')
+pytest.importorskip('httpx')
+web = pytest.importorskip('concordia.examples.astral_canticle.web')
+TestClient = pytest.importorskip('fastapi.testclient').TestClient
+
+HEADERS = {'Origin': 'http://localhost', 'X-Astral-Client': '1'}
+
+
+def request(spec=None, id='first'):
+ return human_input.HumanInputRequest(
+ request_id=id,
+ entity_name='Ilyra Venn',
+ action_spec=spec
+ or entity_lib.free_action_spec(call_to_action='Your move?'),
+ contexts={
+ '__observation__': 'Observations:\n[observation] A silver door.',
+ 'SelfPerception': 'This controlled entity reflects on its own role.',
+ },
+ context=(
+ 'Observations:\n[observation] A silver door.\nThis controlled entity'
+ ' reflects on its own role.'
+ ),
+ )
+
+
+def wait_pending(session):
+ end = time.monotonic() + 4
+ while time.monotonic() < end:
+ if session.snapshot()['pending']:
+ return
+ time.sleep(0.01)
+ raise AssertionError('No pending input')
+
+
+@pytest.fixture
+def active():
+ session = human_io.HumanSession()
+ with concurrent.futures.ThreadPoolExecutor() as pool:
+ result = pool.submit(session, request())
+ wait_pending(session)
+ with TestClient(
+ web.create_app(session), base_url='http://localhost'
+ ) as client:
+ yield session, result, client
+ session.finish('Stopped')
+
+
+def test_reconnect_validate_then_idempotent_submit(active):
+ session, result, client = active
+ first = client.get('/api/state').json()
+ assert client.get('/api/state').json() == first
+ assert 'contexts' not in first['pending']
+ assert first['pending']['context'] == request().context
+ data = {'request_id': 'first', 'response': ' '}
+ assert (
+ client.post('/api/action', json=data, headers=HEADERS).status_code == 422
+ )
+ assert not result.done()
+ data['response'] = 'Open the door'
+ assert client.post('/api/action', json=data, headers=HEADERS).json() == {
+ 'accepted': True
+ }
+ assert result.result(timeout=3) == 'Open the door'
+ assert client.post('/api/action', json=data, headers=HEADERS).json() == {
+ 'accepted': False
+ }
+ data['response'] = 'Actually go west'
+ assert (
+ client.post('/api/action', json=data, headers=HEADERS).status_code == 409
+ )
+ assert len(session.snapshot()['entries']) == 2
+ assert '> Open the door' in client.get('/api/journal').text
+
+
+@pytest.mark.parametrize(
+ 'headers',
+ [
+ {},
+ {'Origin': 'https://attacker.example', 'X-Astral-Client': '1'},
+ {'Origin': 'http://localhost'},
+ ],
+)
+def test_cross_origin_or_simple_post_cannot_act(active, headers):
+ _, result, client = active
+ assert (
+ client.post(
+ '/api/action',
+ json={'request_id': 'first', 'response': 'go'},
+ headers=headers,
+ ).status_code
+ == 403
+ )
+ assert not result.done()
+
+
+def test_bad_host_oversized_and_non_json_requests(active):
+ _, result, client = active
+ assert (
+ client.get('/api/state', headers={'Host': 'attacker.example'}).status_code
+ == 400
+ )
+ assert (
+ client.post('/api/action', content='x', headers=HEADERS).status_code
+ == 415
+ )
+ assert (
+ client.post(
+ '/api/action',
+ json={'request_id': 'first', 'response': 'x' * 40000},
+ headers=HEADERS,
+ ).status_code
+ == 413
+ )
+ assert not result.done()
+
+
+@pytest.mark.parametrize(
+ 'spec,bad,good',
+ [
+ (
+ entity_lib.choice_action_spec(
+ call_to_action='Where?', options=(' North ', 'West')
+ ),
+ 'North',
+ ' North ',
+ ),
+ (
+ entity_lib.float_action_spec(call_to_action='How much?'),
+ 'NaN',
+ '1.5',
+ ),
+ (
+ entity_lib.ActionSpec(
+ call_to_action='Next spec?',
+ output_type=entity_lib.OutputType.NEXT_ACTION_SPEC,
+ ),
+ '{}',
+ '{"call_to_action":"Act", "output_type":"free"}',
+ ),
+ ],
+)
+def test_spec_validation_happens_before_unblocking(spec, bad, good):
+ session = human_io.HumanSession(role='gm')
+ with concurrent.futures.ThreadPoolExecutor() as pool:
+ result = pool.submit(session, request(spec))
+ wait_pending(session)
+ try:
+ with pytest.raises(ValueError):
+ session.submit('first', bad)
+ assert not result.done()
+ assert session.snapshot()['pending']['context'] == request(spec).context
+ session.submit('first', good)
+ assert result.result(timeout=3) == good
+ finally:
+ session.finish('Done')
+
+
+def test_close_unblocks_reader_and_restart_rejects_old_action():
+ session = human_io.HumanSession()
+ with concurrent.futures.ThreadPoolExecutor() as pool:
+ result = pool.submit(session, request())
+ wait_pending(session)
+ session.finish('Closed')
+ with pytest.raises(human_io.InputClosed):
+ result.result(timeout=3)
+ new_session = human_io.HumanSession()
+ with pytest.raises(human_io.StaleRequest):
+ new_session.submit('first', 'go')
+
+
+def test_assets_csp_and_import_do_not_launch_a_simulation(active):
+ _, _, client = active
+ response = client.get('/')
+ assert response.status_code == 200
+ assert 'frame-ancestors' in response.headers['content-security-policy']
+ assert response.headers['cache-control'] == 'no-store'
+ assert client.get('/static/app.js').status_code == 200
+ assert client.get('/docs').status_code == 404
+
+
+@pytest.mark.parametrize('prefix', ['', '/astral-canticle-play'])
+def test_proxy_mount_serves_index_api_and_assets_with_either_prefix(prefix):
+ session = human_io.HumanSession()
+ app = web.create_app(session, root_path='/astral-canticle-play')
+ with TestClient(app, base_url='http://localhost') as client:
+ for suffix in ('/', '/api/state', '/static/app.js', '/static/style.css'):
+ response = client.get(prefix + suffix)
+ assert response.status_code == 200, (prefix, suffix, response.text)
diff --git a/concordia/examples/bellwether/ENGINES.md b/concordia/examples/bellwether/ENGINES.md
new file mode 100644
index 000000000..4cdbc09a9
--- /dev/null
+++ b/concordia/examples/bellwether/ENGINES.md
@@ -0,0 +1,130 @@
+# Analogous scenarios using standard Concordia engines
+
+The main Bellwether night remains **Sequential**. Its agenda, watch boundaries,
+private conversation and consent/repair stages require ordered resolution.
+Changing an engine argument alone is not an equivalent experiment.
+
+## What the repository actually provides
+
+| Standard engine | Scheduling / step semantics | Required configuration | Evidence here |
+|---|---|---|---|
+| `Sequential` | Select one actor, deliver observation, act, resolve; one step per selected actor | One next-actor name, one action spec, ordered GM state | Existing complete Bellwether fixture/live tests |
+| `Simultaneous` | Select a group; get each spec; collect actions concurrently; resolve one multiline batch; one step per round | Group selection, complete-batch GM rule, isolated player contexts; no current-round peer choices in initial observations | New one-round council fixture and batch-contract tests |
+| `Asynchronous` | Independent per-player observe/act loops, no round barrier; per-player iteration cap | ReactiveMeasurements/capture and thread-safe GM/components; explicit rules for concurrent state changes | Code audit only, **not run or certified compatible** with either example |
+
+The modules are in `concordia/environment/engines/`. The simultaneous engine
+already uses standard concurrency utilities and a GM observation/log lock.
+The asynchronous engine validates `ReactiveMeasurements` and has different
+pause/capture behavior. Do not implement another scheduling loop or treat
+async completion order as simultaneous choice.
+
+## Runnable simultaneous resource council
+
+This is a separate, deliberately small analogue of cooperative resource
+governance, **not the full Bellwether game**. Nell, Ivo and Sam each hold one
+unit. Each independently chooses:
+
+- `contribute`: explicitly authorize and execute donation of *their own* unit
+ once the complete batch has arrived;
+- `keep`: retain their unit.
+
+Unlike Bellwether's request/accept/transfer/work stages, this contract makes the
+choice itself authorization for immediate execution. There are no promises,
+watch costs, hidden material effects, majority seizure or claimed social welfare
+metrics. A member's choice cannot supply another member's consent.
+
+From the contribution checkout:
+
+```sh
+python -m pip install -e '.[dev]'
+python -m concordia.examples.bellwether.council --output runs/council-fixture
+```
+
+The command uses standard `NoLanguageModel`, fixed choice ordering and zero
+fixture embeddings. Every fixture actor picks the first choice, `contribute`.
+Expect one completed simultaneous round, three actions, Community3 / members0,
+and the original3 units conserved. Outputs:
+
+- `trace.json` and `trace.html`: standard SimulationLog exports;
+- `outcome.json`: fixture label, complete flag, round count, decisions and
+ inventory for this declared case.
+
+The output proves software wiring and accounting, not cooperation among people.
+One round is a bound, not a method for hiding incomplete results: check
+`complete`, participant set and recorded inventory.
+
+## Composition, reuse and failure semantics
+
+`council.configuration()` returns a fresh standard Config and CouncilLedger.
+It composes existing minimal actors, SwitchAct, NextActingAllEntities,
+FixedActionSpec, MakeObservation/ObservationQueue and Inventory.apply.
+CouncilLedger implements only the *scenario-specific* complete-batch rule.
+The executable uses generic.Simulation with standard Simultaneous; no custom
+engine or server is introduced.
+
+All member/spec names are known before execution. Inputs are exact validated
+choices. The engine may omit a failed actor task from its batch; the ledger
+therefore rejects incomplete, duplicate, unknown or malformed batches **before**
+any inventory mutation. A single Inventory.apply validates all contribution
+deltas. Batch arrival/line ordering does not change the resulting balances.
+Re-resolving the identical complete batch cannot spend resources twice.
+An invalid batch raises an error; no silent partial round or inferred vote is
+treated as success.
+
+The fixed initial public observation contains rules, not other actors' current
+choices. Actions are exposed to the GM only for collective resolution; this
+example has no public browser view or per-user authentication of its own.
+Raw developer traces can contain actor context. Do not publish them unchanged
+if adding private information.
+
+## Plug in real policies or human input
+
+Use the same returned Config with a configured standard language model for
+actors, retaining the explicit moderator components. Reuse the local timeout,
+call-limit and profiling wrappers in `run.py`; a live sample is not a guarantee
+of the all-contribute fixture outcome. Do not call the fixture a live model.
+
+For human inputs:
+
+```python
+from concordia.environment.engines import simultaneous
+from concordia.examples.bellwether import council
+from concordia.prefabs.simulation import generic
+
+config, ledger = council.configuration(
+ human_readers={'Nell': nell_input, 'Sam': sam_input}
+)
+simulation = generic.Simulation(
+ config, model, embedder, engine=simultaneous.Simultaneous()
+)
+log = simulation.play(max_steps=1)
+```
+
+Readers implement the existing HumanInput protocol and must be independently
+routed/thread-safe. Both can be pending concurrently; **do not share one
+blocking terminal prompt or assume the sequential browser's one-active-turn
+projection supports this council unchanged**. The main multiplayer transport
+remains built for Bellwether's ordered night. HumanAct's normal choice validation
+remains in force; other actors keep their standard policies.
+
+## Making another analogous example
+
+1. State the timing semantics: sealed simultaneous intentions, sequential
+ observable moves, or truly asynchronous interactions.
+2. Use standard prefabs and choose the standard engine matching those semantics.
+3. Define what a choice authorizes, what is only speech, and what evidence
+ material transitions need. Keep rules, beliefs, enforcement and compliance
+ distinct.
+4. Test missing/failed actor behavior, partial effects, conservation, privacy,
+ scheduling boundaries and termination before interpretation.
+5. Record engine/model/config/revision and full private traces, then produce
+ intentionally sanitized research outputs. Seeds do not make hosted models
+ deterministic, nor do traces create empirical warrant.
+
+For example, the same council contract can teach independent mutual-aid
+contributions or a cooperative reserve decision. An institutional dispute with
+later observable replies better fits sequential turns. Asynchronous emergency
+reports need their own thread-safe state/observation design; they are a future
+example, not an untested toggle offered here. Component serialization alone
+does not establish engine/checkpoint continuation or scientifically comparable
+branches.
diff --git a/concordia/examples/bellwether/GAME.md b/concordia/examples/bellwether/GAME.md
new file mode 100644
index 000000000..96d50ec67
--- /dev/null
+++ b/concordia/examples/bellwether/GAME.md
@@ -0,0 +1,528 @@
+# A night at Bellwether
+
+You are the emergency coordinator of a small island community. The game runs
+from **Dusk → High Tide → Before Dawn → dawn**, with four consequential choices
+per watch. Looking at the coastal map and resident cards is free. A refused
+request may cost a choice; an ambiguous command does not.
+
+This is a fictional interactive scenario demonstrating standard Concordia
+composition, not a model validated against human behavior. The original
+one-turn `--mode slice` remains available; it is not the full game.
+
+## Start from this contribution's source checkout
+
+```sh
+python -m pip install -e '.[dev]'
+python -m concordia.examples.bellwether.run --mode fixture --editor-port 8786 --player-port 8787 --output runs/bellwether-fixture
+```
+
+Open to play and for the
+developer editor. **Begin the night** (or the editor's `run.start`) starts
+exactly one run. Reopening a tab does not restart it. The fixture banner denotes
+deliberately cooperative, programmed responses, never a live model.
+
+For live residents, first make an existing local Ollama model available:
+
+```sh
+python -m concordia.examples.bellwether.run --mode live --model llama3.2:3b --resident-prefab minimal --editor-port 8786 --player-port 8787 --output runs/bellwether-live
+```
+
+No paid backend is required. This command uses the existing Ollama adapter;
+requests have a 90-second HTTP timeout, 256 output-token cap, and a 256-call
+wrapper limit. The standard per-run profiler records timings and token
+**estimates**, not measured billing. Exhausted/invalid output cannot grant
+consent. Model failures end the run with the retained journal and developer
+error; there is no hidden fixture fallback. Use Ctrl-C to close the owned
+listeners. A new process starts a new night; checkpoint continuation is not
+implemented by this example.
+
+The standard `basic` prefab is an alternative via `--resident-prefab basic`.
+Its perception components make additional model calls. Four residents share the
+selected architecture but have separate standard memory, observations, goals,
+accounts and affiliations. Developers can configure their individual instance
+params in `game_prefab.configuration`. The human uses HumanAct, not an LLM.
+The GM uses independently composed SwitchAct policies and explicit scenario
+rules, not the resident decision logic.
+
+## Optional local memory embeddings
+
+The compatibility default is an eight-dimensional **constant placeholder**.
+It permits fixture plumbing but does not provide meaningful semantic retrieval,
+including when live residents use `basic`. To use real local embeddings, install
+an embedding-capable model separately in your existing Ollama service:
+
+```sh
+ollama pull all-minilm
+python -m concordia.examples.bellwether.run --mode live --model llama3.2:3b --resident-prefab basic --embedding-model all-minilm --output runs/bellwether-embeddings
+```
+
+The optional adapter feeds the standard `generic.Simulation` callable-embedder
+interface and existing `AssociativeMemoryBank`; it does not replace retrieval.
+It uses only `127.0.0.1:11434`, does not inherit proxy settings, and makes no
+automatic model downloads. Capability checks happen before memory transmission.
+Requests use a 30-second HTTP timeout and reject truncation. Vectors must be
+finite, nonzero, and dimension-consistent; they are normalized because the
+standard bank scores with a dot product. A failed request is surfaced, never
+silently changed to a constant embedding.
+
+Developer state and `outcome.json` identify constant, provided-callable, or
+local-model embeddings. The existing private profiler records request counts,
+failures, timings, and dimensions—not memory text or vectors. This metadata is
+not added to player views. Model dimensions, vocabulary and latency vary;
+`all-minilm` has a short input context and can reject long memories. Choose an
+appropriate installed model for your text rather than silently truncating it.
+This is not a quality benchmark or a guarantee that a chosen actor architecture
+will use associative retrieval. Do not mix a saved bank's vectors with a different
+model or placeholder; checkpoint compatibility/continuation is not implemented.
+
+Trusted Python callers can instead pass `embedder=callable` to `Game` or
+`SharedGame`, using their existing embedding library. No web configuration can
+load arbitrary Python callables. Slice mode keeps its original fixture default.
+
+## Playing
+
+Select suggestions to fill the input, or enter a command and your own words.
+There is deliberately a small, conservative physical-action vocabulary.
+Unrecognized or ambiguous physical instructions ask for clarification **before**
+waking the pending human turn. The parser is not a general natural-language
+physical simulator.
+
+| Intent | Example |
+|---|---|
+| Public conversation / open-ended proposal | `tell everyone: Can we find a plan that protects boats and families?` |
+| Address one resident publicly | `tell Mara: What worries you about the cooperative?` |
+| Private conversation | `message Nell: I want to hear your account.` |
+| Ask for material consent | `ask Nell for fuel and part` |
+| Ask for labor consent | `ask Ivo to repair` |
+| Collect the agreed resources | `transfer reserve and part` |
+| Ask Ivo to carry out accepted work | `order repair` |
+| Allocate this watch's supply | `allocate all` or `allocate shelter and cold store` |
+| Make your own promise | `promise shelter` |
+| Withdraw your latest unfulfilled promise | `withdraw my promise` |
+| Advance without agreement | `wait` |
+
+`propose to everyone: …` preserves creative prose for resident discussion.
+It cannot invent a new action mechanic or bypass resource/consent checks.
+Residents respond in their own voices. An explicit structured `accept` records
+only that resident's requested commitment; spoken claims alone are not consent.
+Residents may decline, counter, or revoke their own unfulfilled commitment.
+The next proposal can take their response into account; acceptance is not forced.
+
+Nell's material commitment releases two fuel and the spare part. Collecting them
+is a separate action. Ivo's labor commitment is distinct from performing the
+repair: he may still refuse the work order. Repair requires accepted Ivo labor,
+the delivered part, and actual performance **before Before Dawn**. A late repair
+does not rewrite already-resolved services. General item trades and arbitrary
+new engineering procedures are intentionally not silently adjudicated as valid.
+
+## Material rules and two strategies
+
+The generator has **6 fuel**, Nell has **2 reserve**, and there is **1 spare
+part**. The beacon, shelter and cold store demand one fuel in each of three
+watches: baseline demand 9. A completed repair removes only the final beacon
+demand. The standard Inventory tracks Generator, Nell, Ivo and Used accounts;
+fuel and the part never appear from narration. Requested allocation order
+determines supply priority if there is insufficient fuel. Consumption occurs
+at watch boundaries, after that choice's bounded resident responses.
+
+Two useful plans, not guaranteed live social outcomes:
+
+1. **Negotiate full service:** in Dusk ask Nell for fuel/part, collect them,
+ ask Ivo to repair, then wait. In High Tide order the accepted repair;
+ leave all facilities allocated and use the other choices for promises or
+ discussion. Keep all allocated Before Dawn. With Nell's acceptance and
+ Ivo's accepted **and performed** work, 8 fuel supplies all 9 facility-watches.
+2. **Prioritize shelter and livelihoods:** allocate shelter and cold store in
+ each watch. This consumes 6 fuel, leaves Nell's reserve untouched, but
+ records three missed beacon watches and the associated harbor consequences.
+
+The fixture demonstrates both. Live residents can defeat either social plan
+through refusal or changed commitments. Waiting always allows progression to
+dawn even without agreement. Dawn shows actual service, fuel, repair,
+honored/broken/revoked/unfulfilled commitments, and each resident's response.
+Missed beacon service delays safe arrivals; missed shelter service removes
+heating; missed cold-store service risks stock. These are explicit fictional
+scenario consequences, not predicted casualties or economic estimates.
+
+At High Tide a **disputed** account of the previous storm is delivered to its
+configured recipients. It does not establish objective truth or rewrite
+memory. Use `--dispute-file account.json`:
+
+```json
+{"text": "Mara says the cooperative did not help; Nell disputes that account.", "recipients": ["Coordinator", "Mara", "Nell"]}
+```
+
+The object is validated before building. Institutions distinguish charter,
+membership, enforcement and who knows about the disputed account. The default
+delegates reserve custody to Nell and labor consent to Ivo; membership does not
+grant someone else's consent. Interaction records describe changing positions
+without imposing scalar trust or a single psychological theory.
+
+## One service, three clients
+
+The existing `OperationService`, `SimulationServer` and attached
+`concordia-session` CLI remain the authority. The player GUI uses the same
+`human.respond` handler available on its listener. Developer GUI/CLI edits use
+the same designated `component.edit` as [the original slice](README.md).
+
+Additional operations:
+
+| Operation | Audience | Meaning |
+|---|---|---|
+| `game.begin` | player | Explicit one-time start |
+| `game.preview` | player/developer | Parse an attempt without effects |
+| `run.resume` | developer | Resume the existing paused worker; never replay |
+
+```sh
+concordia-session --url http://127.0.0.1:8786 discover
+concordia-session --url http://127.0.0.1:8786 state
+concordia-session --url http://127.0.0.1:8786 watch
+concordia-session --url http://127.0.0.1:8787 state
+```
+
+A mutation uses the discovered arguments plus the current references/revision
+and an exact retry key, as in README.md. Retry the identical request after a
+lost response; stale edits fail atomically. Selecting Pause does **not** permit
+editing while a worker or human request is still active. The designated edit is
+safe before Run or after the worker has joined. Initial configuration remains
+distinct from runtime. This is not a general mid-run transaction or checkpoint
+interface.
+
+Player HTML/JSON/SSE contain only public facts, the coordinator's delivered
+observations and messages they participated in. The standard observation queue
+separately delivers private events to the relevant residents. Developer
+checkpoints and traces stay on the trusted editor listener; they are never
+hidden in player HTML. Both listeners bind to loopback. This example changes no
+routes, account configuration, lobbies or access controls.
+
+## Verification and limits
+
+```sh
+python -m pip install pytest playwright
+python -m playwright install chromium
+python -m pytest -n 0 concordia/examples/bellwether concordia/contrib/language_models/ollama/ollama_model_test.py
+```
+
+Rule tests exercise conservation, both strategies, refusal, commitment
+revocation, late/missing-part repairs, malformed resident output, atomic
+clarification, recipient privacy and fresh ownership. Chromium tests use real
+components, HTTP, SSE and attached CLI with a synthetic pending input and
+explicitly forbid simulation execution.
+
+Separate browser walkthroughs executed both complete fixture strategies and a
+local live night through standard Simulation/Sequential: 12 human choices each,
+four dawn responses, reload/reconnect recovery, matching CLI snapshots and no
+JavaScript errors. The live run used 12 successful local llama3.2:3b calls
+(about 2.9–6.0 seconds each); Nell declined and no reserve transfer or repair
+was fabricated. Live timing is one machine/run observation, not a guarantee.
+Automated browser submission timing is not a human play-duration measurement.
+
+Logs are standard `simulation.json` and `log.html`; `outcome.json` adds
+the explicit night result, per-step timings, model profile and backend label.
+The 15–25 minute human experience is a target, **not yet measured**. The full
+Bellwether editor/showcase A–H, isolated checkpoint branches, experiments,
+authoring assets/undo/breakpoints, every editor-family parity and fresh
+environment export remain subsequent work. No original workflow acceptance
+baseline is upgraded merely because this bounded game now reaches dawn.
+
+### Standard basic extensions
+
+The optional basic residents retain their standard perception chain and normal
+ConcatAct policy. `basic.Entity` now supports `extra_components` and
+`extra_components_index` using the **same extracted assembly helper** as
+`minimal.Entity`; basic defaults and minimal insertion semantics are unchanged.
+This supplies each resident’s account, affiliations, and scenario instructions
+without copying the basic prefab or silently ignoring configuration. Default
+and injected-policy regressions accompany both advertised configurations.
+
+
+## Two human roles
+
+See [MULTIPLAYER.md](MULTIPLAYER.md) for optional host-approved Coordinator/Nell
+play, private role journals, Android controls, and the HTTPS boundary. Omit
+`--multiplayer` for the unchanged single-human game.
+
+## Optional voice
+
+[Local voice controls](VOICE.md) add opt-in spoken observations and editable
+dictation on supported devices, retaining full text fallback. Android on-device
+recognition is not assumed.
+
+## Contributor recipes
+
+The [researcher starter kit](RESEARCHER.md) maps the architecture and provides
+tested mutual-aid, resource-governance and institutional-dispute variants,
+with explicit assumptions and extension boundaries.
+
+
+## First-play guidance and free interpretation
+
+The role banner states whether this is single-player (human Coordinator plus
+four computer-controlled residents) or a shared night (human Coordinator and
+Nell plus three computer-controlled residents). Fixture residents are labelled
+scripted, not live-model results. **How to play your role** explains your own
+controls; spectators see public information and no action controls.
+
+The Coordinator can type an attempt and choose **Check interpretation · free**.
+This calls the same existing `game.preview` parser available to the attached
+CLI. It does not send an action, reserve resources, spend a choice or predict
+consent/success. Requests, accepted commitments, transfers, performed repairs,
+allocations and service promises stay distinct. A message to everyone is public,
+even if typed with the `message` prefix. Literal speech stays literal text.
+
+Only **Send action** submits an attempt. Checking first is optional. Editing the
+draft or changing turn/role invalidates old interpretations; a delayed reply
+cannot overwrite the interpretation of a newer draft. Connection loss cancels
+the pending preview, and saved drafts reappear only after the service identifies
+the session/role on reconnect. Nell keeps the structured accept/decline/speak UI,
+not the Coordinator's action parser.
+
+Browser regression checks cover the standard service/HTTP parser, free-query
+state equality, malformed/literal messages, stale delivery, reconnect and actual
+host-approved Nell/spectator pages with simulation execution prohibited. They do
+not establish physical Android usability or replace full played-night evidence.
+
+## Reading service status without relying on color
+
+Each map location now says **Not resolved yet**, or names the last resolved
+watch and whether service was **Maintained** or **Unserved**. The generator yard
+shows its current fuel stock. The glow and dashed border are supplementary;
+keyboard and screen-reader names contain the same status in words. Map inspection
+still has no turn cost.
+
+**Service across the watches** shows a native table with watch row headers and
+facility column headers. **Not resolved** means there is no recorded outcome
+for that watch; it does not predict success or failure. Requested allocations and
+promises are not delivered service. The table and map reuse only the existing
+public service records, so spectators receive the same accounting without role
+journals or private conversation data.
+
+Browser checks use one watch resolved through the existing scenario component
+as a deterministic fixture (no Simulation.play/entity.act/model invocation).
+They verify the initial/served/unserved distinction, keyboard-only inspection
+without API mutations, 360px portrait, 800px landscape, forced colors and enlarged
+text. These are software/accessibility checks, not physical-phone or human
+usability validation.
+
+
+## Initial teaching presets
+
+Use `--recipe mutual-aid`, `resource-governance`, or `institutional-dispute`
+with `--mode fixture` or `live` to play a trusted teaching variation through
+the same server and engine. The default is `bellwether`. See
+[RESEARCHER.md](RESEARCHER.md#select-the-same-initial-case-in-the-browser) for
+commands, exact initial-condition changes and interpretation limits. A preset
+is selected only at launch, not applied to a running night. Existing game
+processes are never replaced by this option.
+
+
+## Reading and composing during live updates
+
+Repeated snapshots no longer recreate unchanged resident buttons, action
+suggestions or resident decision options. Keyboard focus stays on the same
+native control. The journal appends new visible events without replacing its
+unchanged prefix, and unchanged context text is retained, so copying a passage
+does not lose the selection merely because another update arrives. Draft text
+and its caret remain yours until an intentional edit or submission.
+
+A changed location filter, completed input session or role boundary still
+updates which controls and content are available. Revocation clears the retained
+journal immediately; rejoining as a spectator never restores the previous
+role's private text or action controls. Retention is local presentation state,
+not a durable checkpoint or a guarantee that an active operation can be cancelled.
+
+Real Chromium checks cover narrow portrait/landscape, native keyboard focus,
+selection through repeated/appended journal snapshots, draft/caret retention,
+changed filter/completion and host revocation/rejoin. These tests republish
+scoped snapshots and component fixtures, without running a simulation or model.
+They are not physical-device or screen-reader usability studies.
+
+## Optional model-side image drafts
+
+[Local visual notes](VISUAL-NOTES.md) provide an explicit image-to-text CLI with
+an installed vision-capable model. On your own turn, the optional image-note
+review control reads its JSON locally, lets you edit it, and explicitly append
+the text to your existing draft before manual Send. It does not upload the note,
+attach images to shared actor context, or automatically submit an action.
+
+
+### Why a resolved service was supplied
+
+In **Service across the watches**, select **Served** or **Unserved** for a
+resolved facility. This free inspection displays the boundary's recorded
+request priority, available fuel and fuel spent, or the completed-repair rule.
+An omitted request is distinguished from a requested facility that lacked fuel.
+Requests are considered in their submitted order; later allocations or stock
+changes do not rewrite the prior explanation.
+
+These are explanations of Bellwether's material accounting rules, not of a
+resident's motives or the legitimacy of an institution. They do not establish a
+scientific causal conclusion. Readable public accounts include the same notes;
+public JSON adds an optional allowlisted service resolution object and demand.
+Older service records without these fields say that boundary details were not
+recorded, rather than inferring a reason from later stocks.
+
+
+### Setup context in a public account
+
+Saved public JSON, readable HTML and the public SVG now carry the trusted
+recipe's declared setup: recipe name, standard actor prefab/engine, human role
+names and initial fuel assumptions. The mutual-aid recipe declares two units
+used before play and six initially available; cumulative Used must not be
+confused with consumption during play.
+
+This optional public projection is **not a complete run configuration**.
+Private component edits, initial memories, prompts, model settings, browser
+identities and session references are excluded. A recipe label neither captures
+all interventions nor guarantees deterministic replay or empirical validity.
+World-only/older records without setup provenance remain explicitly unknown;
+the exporter never infers setup from later stock or narrative.
+
+
+### Player names and watch labels
+
+The player UI calls the Coordinator **Coordinator Alice** and labels the first
+watch **Watch 1 · Early evening**, so a time-of-night badge is not mistaken for
+a resident. High Tide and Before Dawn retain their existing names.
+
+These are display aliases only. Authenticated role IDs, join request values,
+draft recovery keys and entity names remain Coordinator; the first recorded
+watch remains Dusk. Raw dialogue, the human component's assembled context,
+public account records and API/CLI values are not rewritten. The role guide
+explains this mapping. Existing hosted chapters are not reset by this change.
+
+### Sending and uncertain connections
+
+The action area distinguishes **Sending** (confirmation pending), **Action
+received** (delivery confirmed, not a successful outcome), rejection, and an
+unconfirmed network reply. A lost reply does not mean that nothing was sent:
+check the journal before sending again. No automatic retry or fabricated model
+progress is shown. If the same prompt is still open and the draft is unchanged,
+a manual Send reuses the existing server idempotency key.
+
+You can edit your draft while an acknowledgment is pending. A late success
+clears only the unchanged draft that initiated it; re-entering identical text
+counts as a new edit. Revocation or a changed browser role clears the old
+submission's presentation, and its delayed success or failure cannot modify
+the new role's draft. Submission feedback is page-local; reload obtains the
+current scoped server state, while existing draft recovery remains in use.
+
+Chromium regressions cover delayed success, pre-send network loss, a lost
+successful reply, validation rejection, same-key manual retry, same-text edits,
+role revocation/rejoin, and narrow portrait/landscape. They use synthetic human
+input and real server/SSE delivery with simulated client network delays.
+Simulation.play and resident-model calls are blocked; standard Inventory
+construction still uses its NoLanguageModel helper. These are not measured
+phone-network reliability or physical-device usability results.
+
+### Live basic residents and action-only structured output
+
+The live launcher uses two views of the same local model. The ordinary model
+serves standard basic self-perception, situation-perception and
+person-by-situation questions as prose. Only the final standard ConcatAct policy
+uses Ollama's JSON-schema output for the existing decision/speech contract.
+The schema permits every existing decision (including refusal and revocation);
+it does not determine consent, truth or material consequences. The game still
+validates actions and enforces the same resource and commitment rules.
+
+Resident configuration selects the standard prefix_entity_name option as false
+so JSON is not prefilled with a resident name. Minimal now honors that option
+with its previous true default unchanged; basic already supports it. Human
+readers take precedence over the model-backed policy. The action-model binding
+is runtime-only, not a serialized object or an imported module from a project.
+
+For already installed local models, select --mode live --resident-prefab basic
+--model llama3.2:3b --embedding-model all-minilm in the normal source-checkout
+launch command. Use unused private ports and a distinct output directory.
+Sequential remains the engine. Each HTTP request is limited to 90 seconds and
+256 generated tokens; the existing call-limit wrappers reserve 192 context
+calls and 64 action calls, at most 256 total. The shared standard profiler counts
+both paths. No provider/model download or silent embedding fallback is added.
+
+Basic makes additional component-generation requests compared with minimal;
+semantic embedding makes additional local requests. Inspect the profiler and
+invalid_resident_response events, not just HTTP status or a terminal phase.
+Changing prompts or actor/memory configuration can change live behavior, even
+for minimal actors. The provider may reject a schema, time out or truncate an
+answer; those failures are not permission to invent a resident's consent.
+
+Two preliminary basic/local-embedding nights reached dawn but had ten and seven
+malformed resident outputs respectively; successful transport was not valid
+actor behavior. Their evidence is retained. The second private browser harness
+also timed out on a large developer screenshot after reaching dawn; later
+harnesses write machine-readable evidence before optional screenshots.
+A subsequent basic/llama3.2:3b/all-minilm schema-routed night passed strict
+format and Chromium checks: 12 automated human inputs, 24 Sequential steps,
+48 successful local generation calls, 196 embedding requests, zero malformed
+resident responses and no browser errors. It took about 305 seconds of
+automated wall time. Six of nine facility-watches were supplied; no repair was
+forced. This single run does not establish reliability across models or seeds.
+Automated runs do not establish physical Android usability, human play duration,
+deterministic replay or empirical social validity.
+
+
+### Inspecting a large developer snapshot
+
+The attached editor receives authoritative state through the existing service.
+Its Snapshot preview is closed initially and is explicitly abbreviated. Select
+a state section (for example, target or join_requests) to inspect it, and use
+Refresh preview when a newer received revision is indicated. Incoming updates
+do not rewrite the preview being read or reset an unsent operation draft. A
+stale edit is still rejected by the existing server validation.
+
+Download received JSON exports the original last-received event text, including
+large integer identifiers; it does not parse and re-serialize them in JavaScript.
+The preview marks unsafe integer values and limits nested values, text and lists.
+It must not be used as a complete state or checkpoint. The downloaded developer
+snapshot can contain private actor information; it is not the sanitized public
+account, nor is it a complete resumable engine checkpoint. This change reduces
+eager DOM rendering, not server/SSE payload size or
+network cost. Full download occurs only when requested.
+
+### Unavailable resident responses
+
+A malformed resident response is a technical failure, not the resident's
+refusal, uncertainty or speech. The game records a neutral response-unavailable
+notice for the intended task audience without echoing the raw output. No new
+decision, consent or material effect is inferred, and an existing commitment
+is not silently revoked. Valid refusals and other valid decisions retain their
+normal behavior. The turn still progresses; no automatic model retry or free
+replacement player action is added.
+
+At dawn, an unavailable reply is represented by null in dawn_responses and is
+labelled “Response unavailable” in the player view. It is not added to dialogue
+as invented first-person words. Public accounts include only publicly delivered
+failure notices; a private exchange's failure does not become public. The
+private invalid_resident_response diagnostic is retained for developers. Old
+saved records are not rewritten, and schema-constrained models can still fail.
+
+### Interrupted runs in the player view
+
+A closed human-input session is not evidence that a run completed. The player
+view uses the existing run phase to distinguish completion, unexpected stop and
+input closure. After failure, every approved role (including spectators) sees
+a neutral stopped-run notice, not a success or indefinite waiting message.
+Private provider diagnostics are not included in that notice.
+
+Recorded scoped journals, map inspection and the existing public-account
+export remain available while the service is connected. Unsent local drafts
+survive reload. The account export remains labelled interrupted and is not a
+saved game. Reloading does not restart the run; no automatic retry, replay or
+replacement worker is added. Human action/Begin controls stop accepting input,
+and unrelated host-approval messages cannot overwrite the terminal status.
+
+### Current-state delivery to slow clients
+
+The attached SSE listener uses the standard operation service's optional
+coalesced wakeups. It retains at most one pending notification per client,
+instead of materializing and queueing full snapshots that the listener would
+discard. The listener still builds the current authorized snapshot at delivery,
+including rechecking the browser's role. Direct snapshot subscriptions retain
+their existing default behavior.
+
+This is a current-state feed, not a promise to deliver every intermediate
+revision separately. The domain event ledger and latest revision retain all
+published updates. JSON envelopes, reconnect snapshots, heartbeat comments,
+player privacy and read-only exports are unchanged. Full delivered snapshots
+can still be large; this change does not compress their contents or establish
+any model-latency, bandwidth or physical-device performance result.
diff --git a/concordia/examples/bellwether/MULTIPLAYER.md b/concordia/examples/bellwether/MULTIPLAYER.md
new file mode 100644
index 000000000..f321c7cfd
--- /dev/null
+++ b/concordia/examples/bellwether/MULTIPLAYER.md
@@ -0,0 +1,91 @@
+# A shared Bellwether night
+
+This optional mode replaces **only Nell’s acting policy** with HumanActComponent.
+A second person controls Nell while the coordinator makes the twelve watch
+choices. Mara, Ivo and Sam keep the selected standard minimal/basic policies.
+Each human has an independent existing HumanSession. Standard Sequential still
+chooses one entity at a time; there is no separate multiplayer engine.
+
+## Local start
+
+From a source checkout with the Bellwether dependencies installed (see GAME.md):
+
+```shell
+python -m concordia.examples.bellwether.run --mode fixture --multiplayer --editor-port 8788 --player-port 8789 --output runs/shared-night
+```
+
+`fixture` explicitly scripts the remaining AI residents. Use `--mode live
+--model llama3.2:3b` for local Ollama, subject to GAME.md’s model limits. The game
+waits for explicit Begin; neither opening a page nor reloading launches a run.
+Omit `--multiplayer` to retain single-player behavior.
+
+1. The host opens the local editor on port8788. Do **not** publish this listener.
+2. Players open the player page on port8789, enter a name for the host and select
+ Coordinator or Nell. A separate spectator browser can request public-only
+ access. Use separate browser profiles/devices, not two tabs sharing cookies,
+ for separate people.
+3. In the editor’s authoritative state, the host finds `join_requests`. After
+ confirming the intended person out of band, choose `session.approve`, enter
+ that request’s public `id`, and Apply. A browser name is NOT identity proof;
+ do not approve an unknown request because it claims a familiar name.
+4. After both players are approved, either may Begin. The coordinator can ask
+ Nell for fuel and part. Only Nell sees her pending decision control and full
+ own context. Nell may Accept, Decline or offer different terms. Acceptance
+ records consent, not a completed transfer. The coordinator must separately
+ carry out an authorized transfer.
+5. Public discussions appear in both journals; private messages appear only to
+ their recipients. A spectator sees only events delivered to **all** actors,
+ never private accounts or pending human IDs/contexts, and cannot mutate.
+6. Reload or close/reopen a tab to reconnect. This browser retains its role and
+ the server retains its pending turn; draft text is per-session/per-role.
+ Clearing cookies requires a fresh host-approved request. There is no automatic
+ AI takeover or simulated action when a player disconnects.
+7. To move a role to a new device, host `session.revoke` the old request and approve
+ the new one. The game continues waiting for the same pending input. Existing
+ streams lose authorization on their next delivered snapshot. Already viewed
+ data cannot be retracted from a participant’s memory/device.
+
+## HTTPS / Android delivery
+
+This prototype uses established opaque HTTP cookie sessions (Python `secrets`
+and `http.cookies`), not URL credentials or untrusted client role claims. Cookies
+are HttpOnly and SameSite=Strict; role authorization stays in server memory.
+Authenticated snapshots are never cached; SSE re-resolves authorization at each
+delivery. Only the trusted host can approve or revoke a role. Sessions and replay
+keys last for this process, not a disk-restorable account/checkpoint service.
+Admission capacity is128 browser sessions; there is no unbounded guest table or
+silent eviction of an existing player.
+
+For a tailnet HTTPS reverse proxy, set `--public-origin https://your-tailnet-host`
+and `--cookie-path /bellwether/` on a **new** server invocation. Route only that
+player prefix to port8789, stripping the prefix. The page uses relative API URLs.
+These options use Secure cookies and check the exact configured Origin for POSTs;
+request Forwarded headers do not choose or bypass that origin. A path is not part
+of `--public-origin`. Keep the developer listener loopback-only/unproxied.
+Inspect and preserve existing routes; no public Funnel is needed or supported.
+This is host-approved session authorization on top of your tailnet boundary,
+not a claim that a local developer port is remotely authenticated. Never serve
+private play over unencrypted remote HTTP. Local testing without public-origin
+uses non-Secure cookies only on the default127.0.0.1 listeners.
+
+The touch layout includes a responsive facility map,44px-or-larger player
+buttons, visible role/turn status, per-role drafts, a bottom action jump, and
+viewport-resizing/scrollable forms for virtual keyboards. Chromium mobile
+emulation is not evidence of actual Android/Pixel keyboard behavior; see recorded
+validation. No microphone or automatic audio is enabled by this increment.
+
+## Shared operations and boundaries
+
+The host GUI and attached `concordia_session` CLI call the same registered
+`session.approve`/`session.revoke` handlers and revision/retry ledger. Player
+`human.respond` handlers are chosen by the server-resolved role, not a body field.
+The cookie join exchange is authentication setup, not another game mutation API.
+A stale or wrong-role submission has no effects. Repeating a successful envelope
+from the same authorized browser has one effect. Spectators cannot Begin, respond,
+approve roles, or edit components. No private player credentials should be pasted
+into a CLI argument, URL, logs or transcript; use the browser join flow.
+
+Limitations: one shared process, two selected human roles, one approved spectator
+slot, host-mediated enrollment/reassignment, no lobby directory/accounts, no
+cross-server persistence or generalized human-role editor. Arbitrary mechanics,
+checkpoint contracts and BRIEF’s full A–H showcase remain separate work.
diff --git a/concordia/examples/bellwether/PUBLIC-ACCOUNT.md b/concordia/examples/bellwether/PUBLIC-ACCOUNT.md
new file mode 100644
index 000000000..c11479be1
--- /dev/null
+++ b/concordia/examples/bellwether/PUBLIC-ACCOUNT.md
@@ -0,0 +1,118 @@
+# A portable public account
+
+In a full Bellwether night, **Download readable account** saves an offline HTML
+page; **Download public JSON** saves the same public material in a versioned
+machine-readable document. These controls never advance a turn or spend a choice.
+They work during a night and at dawn. An interrupted run is labelled interrupted;
+an unfinished night is not labelled complete.
+
+The export deliberately has fewer fields than the developer trace or a player's
+private journal. It is **not a saved game, checkpoint or executable replay**.
+Do not use it to resume a simulation or infer hidden decisions.
+
+## Public scope, not anonymization
+
+The server reuses `StormNight.view('spectator')`, then selects an explicit
+allowlist of public event text, watch, kind, material inventory, service
+consequences and a small dawn summary. Public events are numbered locally in the
+export; hidden event identifiers and recipients are omitted. Component state,
+private messages, pending action IDs, actor contexts, service identity, cookies,
+hostnames and timing metadata are not exported.
+
+The same operation returns the same public bytes for a player, an approved
+resident, an approved spectator or the local developer. Visitors and revoked
+browser sessions cannot call it. There is no option to request another player's
+private account. The public timeline can still include a participant's names or
+anything they said publicly: review before sharing. Visibility filtering is not
+anonymization, and the underlying model may choose to disclose information in
+public speech. No automatic sharing/upload happens.
+
+HTML is self-contained UTF-8 with escaped text and no JavaScript, external
+resources, forms, frames or embedded live service data. It can be opened offline.
+The UI retains your current draft when a download fails, and refuses a delayed
+download after a connection/role change.
+
+## Attached CLI and research tooling
+
+Use the existing CLI against the **trusted local developer listener** of a
+full-night service built from this checkout, not the one-turn slice. Do not expose
+that listener to other players. With the service already running:
+
+```sh
+printf '%s\n' '{"operation":"game.public_account","arguments":{"format":"json"}}' |
+ python -m concordia.command_line_interface.concordia_session --url http://127.0.0.1:8784 call > public-response.json
+python -c 'import json,pathlib; x=json.loads(pathlib.Path("public-response.json").read_text()); pathlib.Path("bellwether-public-account.json").write_text(x["result"]["content"], encoding="utf-8")'
+```
+
+Choose `html` instead to obtain the readable artifact, or `svg` for the
+public service figure. The shared operation's
+`result` has `filename`, `media_type` and `content`; save **only content**.
+The ordinary operation envelope contains process references for the attached
+transport, and is not the shareable artifact. No mutation revision or retry key
+is needed, and unknown formats are rejected with no effects.
+
+The document schema is `bellwether-public-account/v1`. It records fixture/live
+mode, in-progress/interrupted/completed status, public events and material
+consequences. Export order is public event order, not wall-clock or causal
+evidence. Multiple exports of an unchanged state are identical. Account numbers
+are local display order, not references into private traces. New versions should
+change the schema name before changing this contract.
+
+## Evidence and limits
+
+Tests use real components, the standard HTTP/operation/CLI path and actual
+Chromium downloads, but **seeded records with simulation execution prohibited**.
+They verify cross-role equality, private marker absence, error atomicity,
+read-only state, offline literal markup and mobile width. They are not human
+usability evidence or an additional live game. Prior fixture/live night evidence
+remains separately documented.
+
+The material ledger reports scenario rules; actor prose is not empirical
+validation of real societies. An exported account neither certifies the truth of
+the previous-storm dispute nor makes hosted model output deterministic. Full
+checkpoints, intervention provenance and isolated continuation need separate
+contracts before experimental branch comparison.
+
+
+## A portable public figure
+
+**Download public figure** produces a self-contained SVG through the same
+`game.public_account` operation with `format: "svg"`. It uses the existing
+Matplotlib dependency and the existing public document projection, not private
+logs or screenshots of an authenticated page. No new service, provider upload,
+model call or user-data import is involved.
+
+The figure shows watch-by-facility **Served**, **Unserved** and **Not resolved**
+states in words as well as colors, plus current recorded fuel stocks and spare
+part holders. **Not resolved** is not a prediction; a request or promise does
+not count as supplied service. Used stock remains cumulative, including any
+explicit pre-play consumption. The image is an accounting figure, not a
+geographical map or a model-generated depiction of historical events.
+
+Its SVG title and description provide an accessible text account of every cell
+and stock, with source schema, backend, run status and watch. The same provenance
+is visible on the figure. Public dialogue is deliberately omitted from the
+image—even though it is available in the HTML/JSON timeline. A snapshot marked
+fixture or interrupted must retain that label when shared. Do not crop away
+provenance or treat the figure as empirical warrant for a social conclusion.
+
+SVG has no scripts, forms, external fonts/resources or live service link. Internal
+clip/glyph references and any embedded PNG grid are self-contained. Open it in a
+browser or a compatible image viewer; zoom preserves vector text/axes. Plotting
+work uses owned Figure instances and a renderer lock, without changing unrelated
+pyplot/global settings. Internal IDs are normalized and generation dates omitted
+for repeatable exports from the same state/software/fonts; this does not promise
+byte-identical rendering across plotting-library or font versions.
+
+Real Chromium tests download through the player, compare the existing CLI result,
+open the image offline in narrow portrait/landscape and verify descriptions,
+private-data exclusion, unchanged drafts and no game effects. The screenshot
+captures the SVG element itself: Chromium's full-document capture stalled for
+standalone SVG in this environment, while native element capture and display
+worked. No physical phone, assistive-technology study or played simulation is
+claimed by these checks.
+
+This is **presentation multimodality**, separate from image/audio understanding
+by a model. The inspected installed local chat models report completion/tools
+(and, for Qwen, thinking), not vision/audio input support. This feature neither
+changes Concordia's text model contract nor silently supplies an image to one.
diff --git a/concordia/examples/bellwether/README.md b/concordia/examples/bellwether/README.md
new file mode 100644
index 000000000..1db64b0c1
--- /dev/null
+++ b/concordia/examples/bellwether/README.md
@@ -0,0 +1,156 @@
+# Last Light at Bellwether
+
+**Full three-watch game:** see [GAME.md](GAME.md) for the playable fixture and
+local live-resident modes, the two strategies, and measured validation limits.
+
+## Original shared-service slice (retained with --mode slice)
+
+A **one-turn fixture**, not the complete live game or a research experiment.
+This example teaches a reusable boundary between a human player, the standard
+Concordia visual editor, and a noninteractive attached CLI. All three share one
+`OperationService`; the two HTTP listeners are instances of the existing
+`SimulationServer`, not independent simulations.
+
+## Run from this contribution's source checkout
+
+```sh
+python -m pip install -e '.[dev]'
+python -m concordia.examples.bellwether.run --mode slice --editor-port 8784 --player-port 8785 --output runs/bellwether-fixture
+```
+
+Open for the editor and
+ for the player. **No simulation executes on opening**:
+select `run.start` in the editor's operation panel and Apply once. Then submit
+one proposal from the player screen. The run uses `Simulation.play`, standard
+`Sequential`, minimal actor prefabs, `HumanActComponent`, `SwitchAct`,
+`MakeObservation(allow_llm_fallback=False)`, and fixed context components.
+Four resident actors are instantiated but do not act in this slice. The only
+acting entity is the human coordinator. `NoLanguageModel` is used; no API key,
+paid model, or model download is required.
+
+The fixture acknowledges the action without granting anyone's consent,
+transferring fuel, doing repair work, or predicting a resident's behavior.
+Standard `SimulationLog.to_json()` and `to_html()` outputs are written to the
+chosen directory. Ctrl-C stops these private listeners and cancels pending input.
+
+## A shared edit in both directions
+
+Before Run, select `component.edit` in the editor. Its only parameter, `value`,
+is the exact replacement for Mara's **designated Constant context component**
+`PreviousStormAccount.state`. This is **not an arbitrary episodic-memory edit**.
+Quotes, line breaks, Unicode and literal markup are preserved. Initial prefab
+configuration, other components, and other residents' knowledge stay unchanged.
+The event records before/after, audience origin, operation ID and effective time.
+The updated snapshot appears in all editor clients and the attached CLI:
+
+```sh
+concordia-session --url http://127.0.0.1:8784 discover
+concordia-session --url http://127.0.0.1:8784 state > state.json
+concordia-session --url http://127.0.0.1:8784 watch
+```
+
+For an attached edit, prepare a JSON envelope using a freshly queried revision:
+
+```sh
+python - <<'PY'
+import json
+from pathlib import Path
+state = json.loads(Path('state.json').read_text())
+request = {
+ 'operation': 'component.edit',
+ 'arguments': {'value': 'Mara remembers a disputed account.\nNo one else is informed.'},
+ 'references': state['references'],
+ 'revision': state['revision'],
+ 'retry_key': 'my-edit-1',
+}
+Path('edit.json').write_text(json.dumps(request, ensure_ascii=False))
+PY
+concordia-session --url http://127.0.0.1:8784 call --input edit.json
+concordia-session --url http://127.0.0.1:8784 call --input edit.json
+```
+
+The second call returns the **original** result, not a second edit. Standard
+input is supported with `call --input -`. Use `python -m
+concordia.command_line_interface.concordia_session` if running without installing
+the console entry point. Watch emits one JSON object per SSE state update.
+Timeout/disconnection has a nonzero exit and requires reattaching; it is not a
+durable event-replay cursor. Errors are structured JSON on stderr: exit 2 for
+an HTTP rejection, exit 3 for input/connection errors. A stale revision requires
+querying current state and reviewing the edit, not blindly changing its revision.
+
+## Supported operation inventory
+
+| Operation | Audience | GUI | CLI |
+|---|---|---|---|
+| `session.inspect` | developer | operation selector / state panel | `call` |
+| `component.edit` | developer | exact text field + Apply | `call` |
+| `run.start` | developer | explicit one-turn Run | `call` |
+| `run.pause` | developer | request pause | `call` |
+| `player.view` | player | player screen / filtered state | `call` on player port |
+| `human.respond` | player | action box | `call` on player port |
+
+`discover`, `state`, and `watch` use `/api/operations`, `/api/state` and
+`/api/events`. Every mutation uses `/api/dispatch` with the same registry input
+schema. Only required scalar string/integer arguments are currently supported;
+this is **not a general JSON Schema validator**. Each descriptor documents the
+input fields. Session/project/run/branch references are explicit and
+process-lifetime, not checkpoint identifiers. Retry keys are retained for that
+lifetime; if the ledger reaches capacity, new mutations are rejected rather than
+evicting replay protection.
+
+## Execution and privacy boundaries
+
+A pause flag does not prove quiescence. This first increment deliberately rejects
+edits while the execution thread is alive, **including when awaiting a human**.
+It permits the designated edit before starting, and after the worker has fully
+returned with the controller paused. One run cannot be replaced or restarted.
+Continuing, restoring, cancelling model calls, or editing in-flight work is not
+supported. No engine or StepController stepping semantics are changed.
+
+The editor's graph/inspector remains a read-only view of initial prefab data;
+its attached operation panel explicitly shows last-safe runtime state and traces.
+Legacy run/edit endpoints are disabled for capability-bound listeners so no GUI
+or CLI caller can bypass revisions or the quiescence guard. Normal
+`SimulationServer` users without an operation service keep the existing routes.
+
+The player listener delivers only public scenario configuration and the human
+coordinator's own `HumanSession` snapshot. It never sends developer checkpoints,
+resident private observations, or the edited private account. Unknown/legacy
+player routes are denied; the player cannot select a developer audience in JSON.
+SSE uses that same filtered view and replays a current snapshot on reconnect.
+The service chooses audiences **at listener construction**, not from user input.
+These loopback listeners are not authentication against another process on the
+same machine; the developer port is for a trusted author. Do not publicly expose
+it. The example creates no tailnet route, lobby, credential, or access-control
+configuration. Player and developer browsers must use their designated ports.
+
+## Tests and scope limits
+
+```sh
+python -m pip install pytest playwright
+python -m playwright install chromium
+python -m pytest -n 0 concordia/examples/bellwether/service_test.py concordia/examples/bellwether/browser_test.py
+```
+
+Tests use real standard components and HTTP/CLI/Chromium, but prohibit
+`Simulation.play` in the service/browser suite. They verify exact edit isolation,
+fresh ownership, stale/invalid atomicity, simultaneous retries, actual worker
+liveness versus pause, GUI/CLI semantic parity, reconnect, and player filtering.
+A separate manually launched one-turn fixture walkthrough verifies human input,
+standard sequential resolution and logs. Automated checks are not human usability
+or social/scientific validation.
+
+**Beyond the original one-turn mode:** three-watch accounting, consent/work,
+live residents, two strategies and dawn are now provided by [GAME.md](GAME.md).
+Human 15–25 minute usability remains unmeasured. Still backlog: project authoring,
+full editor-family parity, assets/undo/breakpoints, isolated checkpoint continuation,
+branches, experiments, measurement/export and fresh-environment showcase tours.
+The full Bellwether A–H acceptance and six-workflow baseline are not satisfied by
+this slice. Mechanics values are explicit initial fixture configuration only;
+no narrative can be interpreted as a material or consent change.
+
+## Portable public account
+
+Full-night players and approved spectators can download a script-free public
+timeline and JSON accounting without sharing private journals. See
+[PUBLIC-ACCOUNT.md](PUBLIC-ACCOUNT.md) for scope, CLI use and non-replay limits.
diff --git a/concordia/examples/bellwether/RESEARCHER.md b/concordia/examples/bellwether/RESEARCHER.md
new file mode 100644
index 000000000..8fd0c2577
--- /dev/null
+++ b/concordia/examples/bellwether/RESEARCHER.md
@@ -0,0 +1,332 @@
+# Bellwether researcher starter kit
+
+Bellwether is a fictional teaching scenario and software demonstration. Its
+records establish what *this program* resolved, not facts about a society.
+These recipes help contributors make explicit changes without replacing
+Concordia's engine or confusing narrative with enforcement.
+
+## Start here
+
+From this contribution's source checkout:
+
+```sh
+python -m pip install -e '.[dev]'
+python -m pytest -o addopts='' concordia/examples/bellwether/researcher_test.py -q
+```
+
+The tests construct standard entities and check declared rules; they do not
+call a live model or launch a simulation. To play the unmodified browser game,
+use [GAME.md](GAME.md), or [MULTIPLAYER.md](MULTIPLAYER.md) for host-approved
+Coordinator/Nell sessions. Voice is an optional [presentation aid](VOICE.md).
+
+### A terminal teaching case
+
+Save this as `my_case.py` in your checkout:
+
+```python
+from pathlib import Path
+import numpy as np
+
+from concordia.environment.engines import sequential
+from concordia.examples.astral_canticle.terminal import TerminalInput
+from concordia.examples.bellwether import game_prefab, researcher
+from concordia.prefabs.simulation import generic
+
+case = researcher.prepare_case('institutional-dispute', TerminalInput())
+simulation = generic.Simulation(
+ case.config,
+ game_prefab.FixtureModel(), # scripted decisions, not live residents
+ lambda text: np.zeros(8), # fixture-only embedding, no semantic retrieval
+ engine=sequential.Sequential(),
+)
+log = simulation.play(max_steps=64)
+output = Path('runs/my-case')
+output.mkdir(parents=True, exist_ok=True)
+(output / 'trace.json').write_text(log.to_json(), encoding='utf-8')
+(output / 'trace.html').write_text(log.to_html(), encoding='utf-8')
+```
+
+Run `python my_case.py`. The standard human component prints the controlled
+entity's ordered context and asks for your text. Use the commands in GAME.md.
+The scripted fixture cooperates on requests; use it to check wiring, not to
+measure social strategy. A max-step limit may stop before dawn; inspect
+`case.world.finished` rather than calling a partial trace a complete night.
+
+The recipe helper only prepares a standard Config and fresh scenario component.
+It does **not** run an engine, start a server, upload data, import user-supplied
+Python, or serialize live human readers. Call it anew for every run; do not
+reuse a built Config's attached components. These are headless/terminal recipe
+examples, not a new browser project editor. The browser supports explicit recipe
+selection and a separate baseline `--dispute-file` option as described below.
+
+## Architecture and ownership
+
+```mermaid
+flowchart TD
+ R[Trusted recipe / initial assumptions] --> C[Config + InstanceConfig]
+ C --> S[standard generic.Simulation]
+ S --> A[standard minimal/basic actors]
+ S --> G[GM: minimal + SwitchAct]
+ A --> H[HumanAct or ConcatAct]
+ G --> W[StormNight scenario rules]
+ W --> I[standard Inventory.apply]
+ W --> O[standard ObservationQueue / MakeObservation]
+ S --> E[standard Sequential]
+ E --> L[SimulationLog / JSON / HTML]
+ B[Browser / attached CLI] --> P[shared OperationService]
+ P --> Q[standard human inbox]
+ Q --> H
+```
+
+| File / symbol | Responsibility | Must not be treated as |
+|---|---|---|
+| `researcher.prepare_case` | Fresh trusted Config/world and declared initial assumptions | Arbitrary project serialization or experiment runner |
+| `game_prefab.configuration`, `Resident.build` | Standard prefab selection and per-actor goals, private accounts, affiliations and optional human readers | New agent architecture |
+| `game.StormNight` | Explicit agenda, consent, repair, watch accounting, recipient projection | Alternative simulation engine or a social theory |
+| `Inventory.apply` | Atomic validated item deltas | LLM narration of a transfer |
+| `SwitchAct` + `Signal` | GM action-spec, next-actor and termination routing | A human or narrator overriding consent |
+| `MakeObservation` + `ObservationQueue` | Deliver only addressed observations | A globally visible event feed |
+| `generic.Simulation` + `Sequential` | Construct entities, execute standard phases and record logs | A deterministic hosted-model replay promise |
+| `game_service.Game` | One browser run, revisioned operations and lifecycle | Initial-project editing or checkpoint restoration |
+| `multiplayer.SharedGame` | Independent approved role views and existing human inboxes | Remote authentication based on a port or role name |
+| `SimulationLog` | Recorded prompts/events/state evidence | Empirical warrant for a policy conclusion |
+
+The GM and each entity own distinct context components; each new case owns
+its inventory and observation queue. Standard minimal/basic builds retain their
+normal components and acting order. Basic's perceptions make additional model
+calls; enabling a human final action does not automatically disable its
+LLM-backed context processing.
+
+## Included analogous recipes
+
+All cases deliberately retain the five fixed Bellwether role IDs and three
+facility IDs so existing parsing, scheduling and accounting remain meaningful.
+They are variations of one resource-sharing skeleton, not arbitrary-world
+generators.
+
+| Recipe | Explicit change | Expected software effect | Not implied |
+|---|---|---|---|
+| `bellwether` | Original initial context and ledger | Baseline8 fuel,1 part,9 facility-watches | Fixture cooperation predicts people |
+| `mutual-aid` | Ferry evacuation framing, Sam's goal/private report,2 fuel already consumed before play | Generator4 + reserve2 + Used2; only6 usable fuel, so even repair leaves unmet demand | Missing fuel vanished; the rumor is objective fact |
+| `resource-governance` | Proposed transparent reserve charter and Nell's goal | Same text appears in institutional view and member context; dialogue may differ with a live model | New voting, sanction or veto enforcement exists |
+| `institutional-dispute` | Different private recollections; High Tide note goes only to Mara/Nell | Coordinator/Ivo/Sam/spectator do not receive the direct note | Recipients cannot choose later to disclose it; either recollection is true |
+
+`case.manifest` records fixed IDs, engine, human roles, accounting baseline and
+evidence class without exporting private accounts. For mutual aid,
+`epilogue.fuel_used` is the **cumulative Used ledger**, including2 pre-play units.
+Subtract `manifest['fuel_consumed_before_play']` to measure in-run consumption.
+Do not compare it to the baseline as if both started at zero.
+
+## Tested modifications and extension boundaries
+
+### Private information and setting
+
+The recipe changes both the actor's `PreviousStormAccount` Constant and the
+recipient-scoped initial observation. It does not leave a conflicting default
+account in the queue. `StormNight.seed(opening=..., accounts=...)` validates all
+resident names/text before emitting anything. Use `world.emit(..., audience=[...])`
+for delivered facts/claims; a character goal or hidden account is not public.
+Public player projections must not include developer checkpoints or raw logs.
+
+For browser use, `run --dispute-file file.json` accepts exactly:
+
+```json
+{"text":"A disputed claim, not established history.","recipients":["Mara","Nell"]}
+```
+
+This changes a delivered account, not past objective events. Never patch module
+globals to customize simultaneous runs.
+
+### Institutions: rule, belief, enforcement, compliance
+
+`StormNight(..., institutions=[...])` accepts unique named institutions with
+known members and text `rule`/`enforcement` fields. The same per-world list
+feeds member affiliation components and the player view; defaults are unchanged.
+For example:
+
+```python
+institutions = [{
+ 'name': 'Mutual aid council',
+ 'members': ['Nell', 'Ivo', 'Sam'],
+ 'rule': 'Propose public notice before reserve release.',
+ 'enforcement': 'Proposal only; existing owner consent remains the gate.',
+}]
+```
+
+Text is an actor-facing institutional description, **not executable authority**.
+Changing actual enforcement requires a reviewed change to the relevant
+`StormNight._human` / `_resident` transitions, explicit new records and tests
+for refusal, revocation, unauthorized attempts and boundary timing. Do not
+replace the consent gate with a prompt asking a narrator to infer compliance.
+Membership, agreement, intended action and performed labor are separate facts.
+
+### Scarcity, repair and outcomes
+
+The supported teaching scarcity variation uses standard inventory transfers to
+record prior consumption. Total conservation stays8 fuel and1 part. Changing
+total resource supply, transfer amounts or facility demand beyond that requires
+coordinated edits to `new_inventory`, invariants, reserve contract, opening,
+repair/boundary rules, UI and outcome definitions. A hidden Constant edit alone
+does not change inventory and must not be reported as a scarcity intervention.
+
+Likewise, changing `CONSEQUENCES` wording changes presentation, not the number
+of supplied facility-watches. Add a new outcome only with an explicit data
+source, denominator and interpretation. Tests should check changed transitions,
+not assert a desired political narrative or winner.
+
+### Actor policy, human roles and roster
+
+```python
+case = researcher.prepare_case(
+ 'resource-governance',
+ coordinator_reader,
+ actor_logic='basic',
+ human_readers={'Nell': nell_reader},
+)
+```
+
+Both readers use the existing transport-neutral HumanInput protocol; role
+authorization remains the transport's responsibility. Other residents retain
+ConcatAct. Choose `minimal` or `basic` without copying their build functions.
+Individual trusted InstanceConfig `decision_logic`, `goal` and `account`
+parameters can be edited **before** building Simulation.
+
+For live actors supply a local model through standard
+`Simulation(..., override_agent_model=...)`, retaining the explicit GM policy.
+Reuse the call-limit, profiling and bounded Ollama configuration in `run.py`;
+do not silently fall back to fixture behavior on model errors. Zero embeddings
+in the teaching snippet are not a realistic basic-agent retrieval setup.
+For real local vectors use the optional callable adapter described in
+[GAME.md](GAME.md#optional-local-memory-embeddings). The same embedder can be
+passed directly to standard `generic.Simulation` in this terminal example.
+Record the selected embedding model alongside the actor model and assumptions;
+one small successful retrieval probe is not evidence of general memory quality.
+
+Humanizing a known resident via `human_readers` is supported. Arbitrary roster
+size is **not** a one-line Bellwether option: add role/prefab instances, parser
+targets, request ownership, observation recipients, agenda/dawn rules, role UI
+and invariants together. The current browser authorizes only Coordinator/Nell
+plus a spectator. Standard Config supports other entities, but this scenario's
+fixed consent and resource contracts still name Nell/Ivo. Unknown role mappings
+are rejected rather than silently ignored.
+
+### GM policy and other engines
+
+The existing GM composes standard SwitchAct with explicit rules. Alternative
+GM context/action components can be composed through a trusted Config, but must
+implement the selected engine's action-spec and next-actor contracts and
+preserve declared observation/privacy boundaries. A new narrator is not
+permission to grant consent or invent resources.
+
+The main game stays Sequential. Merely passing another engine is not a verified
+variant: different scheduling/action-spec contracts can invalidate an agenda
+designed for one actor at a time. A subsequent standard-engine example should
+document and test those differences, not implement a replacement loop.
+
+## Validation, traces and research limits
+
+`researcher_test.py` constructs every recipe using standard Simulation,
+checks minimal/basic human-vs-AI policies, fresh component ownership, exact
+private account seeding, recipient-bound dispute delivery, valid/invalid
+institution state and conservation. Direct rule tests are unit checks, not
+live-model runs. The bounded integration smoke uses a scripted fixture and
+standard Sequential; a completed night still says nothing about human validity.
+
+Keep the original configuration, explicit assumptions, backend/model/options,
+software revision, human inputs, intervention timestamps and full raw trace
+when doing private analysis. Logs include private prompts/memories: do not give
+raw JSON/HTML to a player or public spectator. Export only intentionally
+sanitized recipient projections. Seeds with hosted models are not a guarantee
+of deterministic replay. Component state serialization is not a complete
+checkpoint/engine continuation contract; create new independent cases until
+that contract is implemented and verified.
+
+Research needs plural decision logic and a defensible design, not just fluent
+dialogue: state hypotheses in advance, justify observations/outcomes, record
+counterexamples, separate rules from beliefs/enforcement/compliance, and seek
+external evidence. No scripted scientific conclusion, policy recommendation,
+trust score, human usability result or social generalization follows from this
+fixture, these tests, or a single local-model night.
+
+## Standard-engine companion
+
+[ENGINES.md](ENGINES.md) provides a runnable simultaneous resource council,
+actual scheduling/API differences, and explicit asynchronous limitations.
+The main Bellwether night remains Sequential.
+
+## Select the same initial case in the browser
+
+From this source checkout, choose a named trusted recipe explicitly:
+
+```sh
+python -m concordia.examples.bellwether.run --mode fixture \
+ --recipe mutual-aid --editor-port 8784 --player-port 8785 \
+ --output runs/mutual-aid-fixture
+```
+
+Open the printed **Player** URL. The page labels the teaching case and its
+initial assumptions. **Begin the night** is explicit; opening/reloading the
+page never runs or resets a simulation. The server uses the exact
+`prepare_case` Config/world described above, not a copied recipe interpreter.
+For the host-approved two-human path add `--multiplayer`; Coordinator and Nell
+retain their own input and observation scope. The host must approve both players.
+Other residents keep the selected standard minimal/basic policy.
+
+Choose from `bellwether` (unchanged default), `mutual-aid`,
+`resource-governance`, or `institutional-dispute`. To use available local
+residents, replace `--mode fixture` with `--mode live --model llama3.2:3b`;
+fixture results do not establish live behavior. This change verifies initial
+recipe selection, not another live night or human usability result.
+
+`--recipe` is an initial-launch choice, **not** an editor operation for changing
+an active game. Use a different output directory and unused ports for each
+independent night. The one-turn `--mode slice` excludes non-default recipes.
+`--dispute-file` is supported only with `--recipe bellwether`; combining it
+with another case is rejected before reading the file or constructing a model
+or service. No file may supply importable Python or a new recipe name.
+
+The mutual-aid case starts with Generator4 + reserve2 + Used2. **Used is a
+cumulative ledger**: two units were consumed before play. The browser labels
+this condition and the public account includes the declared opening/initial
+condition events. The dawn `fuel_used` field remains cumulative for compatibility;
+subtract the manifest's `fuel_consumed_before_play` when measuring in-night
+consumption. This is not a post-hoc deletion of resources or an experimental
+comparison with a shared baseline.
+
+The saved `outcome.json` player projection and developer initial view include
+the public recipe manifest (not private account text). Existing JSON/HTML raw
+traces still contain private context and must remain host-only. The public
+account continues to omit recipient-only dispute notes and private accounts.
+
+Regression checks compare exact initial actor states, Config instances and
+worlds against independent headless builds for every recipe and both standard
+policies. Chromium verifies 360px openings, initial stocks/charters, reload and
+host-approved recipient-only dispute delivery using a component fixture. These
+checks do not invoke Simulation.play or a model; existing full-game execution
+wiring is unchanged. Checkpoint, arbitrary project authoring, experimental and
+physical-device acceptance remain separate.
+
+## Image input versus presentation
+
+The public service figure is a presentation artifact. [Visual notes](VISUAL-NOTES.md)
+instead send bounded normalized pixels to an explicitly selected local vision
+model and return a separate human-review draft. There is no automatic path into
+an actor observation or scientific evidence. The standard actor interface stays
+text-only; do not attach a player's private image to a model instance shared by
+other roles.
+
+
+### Setup context in a public account
+
+Saved public JSON, readable HTML and the public SVG now carry the trusted
+recipe's declared setup: recipe name, standard actor prefab/engine, human role
+names and initial fuel assumptions. The mutual-aid recipe declares two units
+used before play and six initially available; cumulative Used must not be
+confused with consumption during play.
+
+This optional public projection is **not a complete run configuration**.
+Private component edits, initial memories, prompts, model settings, browser
+identities and session references are excluded. A recipe label neither captures
+all interventions nor guarantees deterministic replay or empirical validity.
+World-only/older records without setup provenance remain explicitly unknown;
+the exporter never infers setup from later stock or narrative.
diff --git a/concordia/examples/bellwether/VISUAL-NOTES.md b/concordia/examples/bellwether/VISUAL-NOTES.md
new file mode 100644
index 000000000..510406198
--- /dev/null
+++ b/concordia/examples/bellwether/VISUAL-NOTES.md
@@ -0,0 +1,100 @@
+# Optional local visual notes
+
+This is **model image input**, separate from the public SVG presentation and
+browser speech controls. The model call is an explicit local command-line aid, not a player
+image-upload endpoint. The result is a draft for you to inspect and edit, never an
+automatic observation, game action, consent decision, or source of inventory.
+
+## Use an installed local vision model
+
+From this contribution's source checkout, use the existing Ollama service.
+Model setup is separate; the tool never downloads a model implicitly:
+
+```sh
+ollama pull qwen3-vl:2b
+python -m concordia.examples.bellwether.visual_note \
+ --model qwen3-vl:2b --image public-diagram.png \
+ --question "Describe the visible objects and their positions." \
+ --output visual-note.json
+```
+
+Review the JSON's `text` alongside the original image. The draft includes the
+chosen model, source/transmitted hashes and dimensions, stop reason, output
+limit, and private request timings. It is always labelled
+`required_before_use`. A `length` stop reason indicates an incomplete answer.
+An existing output file is not overwritten. To use any text in a game, explicitly
+copy and edit it in your own pending command, check the interpretation, and send
+it yourself. The tool has no game/session connection and cannot send it for you.
+
+Only PNG, JPEG and WebP **still images** are supported. Input is limited to
+8 MiB and four megapixels; Pillow applies orientation, composites transparency
+on white, reduces the longest edge to at most 1024 pixels, and reencodes PNG
+without EXIF, comments or other metadata. Small writing/details can be lost on
+resize. SVG, animations, PDF, URLs, camera capture and audio are not accepted.
+The existing Matplotlib dependency supplies Pillow; no new browser framework
+or cloud service is needed.
+
+The tool sends the normalized pixels and your question only to
+`127.0.0.1:11434`, ignoring proxy settings. A model must report vision support
+before pixels are sent. Requests use a 90-second HTTP timeout and 256 output-token
+limit, do not retry automatically, and ask Ollama to unload this model afterward.
+The standard profiler records counts/latency, not pixels or question text.
+The JSON note **does** retain the question and model description: it may contain
+private information and is not the sanitized public-account export. It omits
+the image bytes, original filename and local file path.
+
+## Evidence and interpretation
+
+Unit checks use synthetic pixels and a mocked SDK. They verify decoding/limits,
+EXIF orientation and metadata removal, actual normalized pixel payloads, capability
+checks, request bounds, plain-text/literal JSON, and no output on transport or
+validation failure. No simulation is executed by these checks.
+
+A separate local probe uses two public synthetic diagrams with identical
+questions and reversed positions of a red circle and blue square. Record the
+actual descriptions, mismatches and timing when trying this on a model. Passing
+those examples only establishes a small image-conditioned functional result,
+not general recognition/OCR accuracy or reliability on scientific figures.
+Do not infer perception quality merely from the model's capability flag.
+
+One local `qwen3-vl:2b` run described both synthetic diagrams correctly with
+the positions reversed, using the identical question. Each request took about
+16.2 seconds on that machine, including model loading; the model was unloaded
+between requests. These two observations do not establish OCR/diagram accuracy
+on other images or a latency guarantee. No game operations or simulation steps
+were executed.
+
+Image text and model text are untrusted data. The note can be mistaken or contain
+instructions; nothing here executes them. Review against the image before any
+reuse. No broad image/audio understanding, physical Android image workflow,
+automatic actor perception, or social validity follows from this tool.
+For text-only play or unsupported models, keep using the normal player input.
+
+## Review a note in the player page
+
+On your own pending turn, open **Review an image note · optional** and choose
+the generated visual-note JSON. The native file reader reads it in your browser:
+the file, image, question and source metadata are not uploaded to the game.
+The page verifies the bounded file structure, not the truth of its contents or
+its claimed model/source. Source details are available separately from the
+editable description.
+
+Edit the description, then choose **Add reviewed text to action draft**. This
+appends the literal text without replacing your existing draft or submitting it.
+Review the complete command and, for a resident role, your selected decision.
+Only the existing **Send action** submits anything. Long text may need trimming
+to satisfy the existing action input limit; the importer never silently truncates.
+
+Invalid imports preserve your draft and the previous edited review. Clear review
+cancels outstanding file reads and leaves your action draft unchanged. A newer
+file, role/turn change, connection loss or reload discards old review data and
+late responses. Only text you explicitly added to the action draft uses the
+existing role-scoped draft recovery. Spectators and browsers without a pending
+controlled turn cannot use the review controls.
+
+Chromium checks cover narrow portrait/landscape, unchanged-SSE focus/selection,
+UTF-8/format/size errors, cancel/replacement of delayed reads, approved Nell's
+private review, revocation, reconnect and explicit Send. These are emulated
+browser checks with synthetic inboxes and no simulation or live image calls,
+not physical Android validation. Image selection/inference from the phone is
+not implemented by this JSON review step.
diff --git a/concordia/examples/bellwether/VOICE.md b/concordia/examples/bellwether/VOICE.md
new file mode 100644
index 000000000..15590da84
--- /dev/null
+++ b/concordia/examples/bellwether/VOICE.md
@@ -0,0 +1,59 @@
+# Optional local voice
+
+Bellwether remains fully playable with text. Open **Voice options** and enable
+voice for the current page. This preference resets on reload; nothing plays or
+records automatically.
+
+- **Read latest observation** reads only the latest observation already visible
+ in your role's journal, using a browser voice reporting `localService=true`.
+ Use **Stop audio** to interrupt. Private observations are never auto-read.
+- **Dictate on this device** checks for an already installed language matching
+ the browser's language. Only recognition supporting `processLocally=true`
+ and `available({processLocally:true}) === 'available'` is started. It never
+ falls back to remote recognition or automatically downloads a language pack.
+- Finish or cancel recording, edit the separate transcript, then choose
+ **Add to action draft**. This appends to any text you typed meanwhile.
+ **Send action** is still a separate, manual step.
+- Text and journal captions stay available when audio is unsupported or denied.
+ Reconnection, role revocation, a changed turn, leaving the page or hiding the
+ tab stops voice and discards unadded transcript. Your ordinary action draft
+ retains its existing recovery behavior.
+
+## Browser support and validation limits
+
+On-device recognition is experimental and not a universal Web Speech feature.
+MDN browser-compat-data inspected on 2026-09-09 lists `processLocally` for desktop
+Chrome139+, but **not Chrome for Android**, Firefox or Safari. An Android browser
+can expose ordinary speech recognition while lacking the on-device contract:
+this example deliberately does not invoke that potentially remote service.
+Typing therefore remains the Android dictation fallback until the browser has
+the required local capability. Read-aloud is available only if that device
+actually lists a local voice; a remote/default voice is never substituted.
+
+References:
+- [MDN on-device recognition](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API#on-device_speech_recognition)
+- [processLocally](https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition/processLocally)
+- [localService](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisVoice/localService)
+- [Browser compatibility data](https://github.com/mdn/browser-compat-data/blob/main/api/SpeechRecognition.json)
+
+The committed Chromium tests use explicitly mocked speech events to verify
+opt-in, local-only selection, editable transcripts, permission/cancel errors and
+revocation/late-callback isolation. A separate unmodified-browser probe records
+API availability without starting a microphone or producing audio. Those are
+software contract checks, **not** physical Pixel testing, audible-quality or
+speech-recognition accuracy measurements.
+
+## Data and scope
+
+No microphone stream, audio file, or transcript is uploaded to an audio
+provider by this example. Only the final manually submitted text goes through
+the existing human-action API; the game may deliver that text to the intended
+recipients and configured resident model as usual. Local speech depends on the
+browser/OS honoring the Web Speech on-device contract. There is no voice cloning,
+audio storage, speech identity, background listening or model audio understanding.
+
+The coastal map is CSS/DOM presentation of the scenario's named locations and
+the last resolved facility state. It is not satellite imagery or a model's
+visual perception. Its button labels and journal supply the text equivalent.
+This increment adds presentation/input accessibility, not audio/image reasoning
+or evidence about real human behavior.
diff --git a/concordia/examples/bellwether/__init__.py b/concordia/examples/bellwether/__init__.py
new file mode 100644
index 000000000..42085cbfa
--- /dev/null
+++ b/concordia/examples/bellwether/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bellwether: a bounded three-mode fixture, not the full live game."""
diff --git a/concordia/examples/bellwether/boundary_explanation_browser_test.py b/concordia/examples/bellwether/boundary_explanation_browser_test.py
new file mode 100644
index 000000000..de5c10790
--- /dev/null
+++ b/concordia/examples/bellwether/boundary_explanation_browser_test.py
@@ -0,0 +1,122 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Read actual boundary diagnostics in Chromium, with no engine execution."""
+
+from concordia.examples.bellwether import game as rules
+from concordia.examples.bellwether.focus_browser_test import host_fixture # pylint: disable=unused-import
+from concordia.examples.bellwether.focus_browser_test import publish
+from concordia.examples.bellwether.focus_browser_test import track_snapshots
+import pytest
+
+pwlib = pytest.importorskip('playwright.sync_api')
+
+
+@pytest.mark.parametrize('width,height', [(360, 800), (800, 360)])
+def test_free_boundary_inspection_matches_record_and_survives_sse(
+ host, tmp_path, width, height
+):
+ game, url = host()
+ game.world.transfer('Generator', 'Used', 'fuel', 5)
+ for words in ['allocate cold store and shelter', 'wait', 'wait', 'wait']:
+ game.world.resolve(rules.PLAYER, words)
+ records = game.world.view()['services']
+ before = game.world.get_state()
+ with pwlib.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ try:
+ page = browser.new_page(
+ viewport={'width': width, 'height': height}, has_touch=True
+ )
+ track_snapshots(page)
+ errors, posts = [], []
+ page.on('pageerror', lambda e: errors.append(str(e)))
+ page.on(
+ 'request',
+ lambda r: posts.append(r.url) if r.method == 'POST' else None,
+ )
+ page.goto(url)
+ page.locator('#service-history summary').click()
+ assert page.locator('#service-rows button').count() == 3
+ for record in records:
+ label = 'Watch 1 · Early evening · ' + record['facility'] + ' · '
+ button = page.get_by_role(
+ 'button',
+ name=label
+ + ('Served' if record['served'] else 'Unserved')
+ + ' · Boundary details',
+ exact=True,
+ )
+ before_click = game.operations.snapshot('developer')
+ button.focus()
+ page.keyboard.press('Enter')
+ expected = (
+ label[:-3]
+ + '\n'
+ + record['resolution']['explanation']
+ + '\n'
+ + record['consequence']
+ )
+ pwlib.expect(page.locator('#service-explanation')).to_have_text(
+ expected
+ )
+ assert game.operations.snapshot('developer') == before_click
+ publish(game, page)
+ pwlib.expect(button).to_be_focused()
+ pwlib.expect(page.locator('#service-explanation')).to_have_text(
+ expected
+ )
+ bounds = button.bounding_box()
+ assert bounds is not None and bounds['height'] >= 44
+ assert game.world.get_state() == before
+ assert not posts and not errors
+ assert page.evaluate('document.documentElement.scrollWidth') <= width
+ page.screenshot(path=str(tmp_path / 'boundary-notes.png'), full_page=True)
+ page.reload()
+ page.locator('#service-history summary').click()
+ page.locator('#service-rows button').first.click()
+ pwlib.expect(page.locator('#service-explanation')).to_contain_text(
+ 'not requested'
+ )
+ page.add_style_tag(content=':root{font-size:32px}')
+ assert page.evaluate('document.documentElement.scrollWidth') <= width
+ assert game.world.get_state() == before
+ assert not posts and not errors
+ finally:
+ browser.close()
+
+
+def test_legacy_unknown_and_literal_text_remain_inert(host):
+ game, url = host()
+ for _ in range(4):
+ game.world.resolve(rules.PLAYER, 'wait')
+ game.world.data['services'][0].pop('resolution')
+ words = ' & literal details'
+ game.world.data['services'][1]['resolution']['explanation'] = words
+ with pwlib.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ try:
+ page = browser.new_page()
+ page.goto(url)
+ page.locator('#service-history summary').click()
+ page.locator('#service-rows button').nth(0).click()
+ pwlib.expect(page.locator('#service-explanation')).to_contain_text(
+ 'Boundary details were not recorded.'
+ )
+ page.locator('#service-rows button').nth(1).click()
+ pwlib.expect(page.locator('#service-explanation')).to_contain_text(words)
+ assert page.locator('#service-explanation img').count() == 0
+ assert not page.evaluate('Boolean(window.injected)')
+ finally:
+ browser.close()
diff --git a/concordia/examples/bellwether/boundary_explanation_test.py b/concordia/examples/bellwether/boundary_explanation_test.py
new file mode 100644
index 000000000..fa9c469e7
--- /dev/null
+++ b/concordia/examples/bellwether/boundary_explanation_test.py
@@ -0,0 +1,148 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Recorded boundary reasons, not reconstructed motives or social findings."""
+
+import copy
+import json
+
+from concordia.examples.bellwether import game_test
+from concordia.examples.bellwether import public_account
+import pytest
+
+
+def record(value, facility, watch='Dusk'):
+ return next(
+ row
+ for row in value.view()['services']
+ if row['watch'] == watch and row['facility'] == facility
+ )
+
+
+def test_not_requested_is_distinct_from_fuel_shortfall():
+ value = game_test.world()
+ for words in ['allocate shelter and cold store', 'wait', 'wait', 'wait']:
+ game_test.attempt(value, words)
+ detail = record(value, 'beacon')['resolution']
+ assert detail['basis'] == 'not_requested'
+ assert detail['requested'] is False
+ assert detail['priority'] is None and detail['fuel_before'] is None
+ assert detail['fuel_spent'] == 0
+ assert 'not requested' in detail['explanation'].lower()
+ assert value.inventory_state()['Generator']['fuel'] == 4
+
+
+def test_actual_allocation_order_and_before_fuel_are_captured_once():
+ value = game_test.world()
+ value.transfer('Generator', 'Used', 'fuel', 5)
+ for words in [
+ 'allocate cold store and shelter and beacon',
+ 'wait',
+ 'wait',
+ 'wait',
+ ]:
+ game_test.attempt(value, words)
+ rows = {
+ name: record(value, name) for name in ('beacon', 'shelter', 'cold store')
+ }
+ cold = rows['cold store']['resolution']
+ assert cold['basis'] == 'fuel_supplied'
+ assert (cold['priority'], cold['fuel_before'], cold['fuel_spent']) == (
+ 1,
+ 1,
+ 1,
+ )
+ for name, position in [('shelter', 2), ('beacon', 3)]:
+ detail = rows[name]['resolution']
+ assert detail['basis'] == 'fuel_shortfall'
+ assert detail['priority'] == position
+ assert detail['fuel_before'] == 0 and detail['fuel_spent'] == 0
+ assert detail['requested'] is True
+ saved = copy.deepcopy(value.view()['services'])
+ # Later stock/requests must not retroactively change the prior reason.
+ value.transfer('Nell', 'Generator', 'fuel', 1)
+ game_test.attempt(value, 'allocate beacon')
+ assert value.view()['services'] == saved
+ value.invariant()
+
+
+@pytest.mark.parametrize(
+ 'strategy', [game_test.FULL_SERVICE, game_test.PRIORITIZE]
+)
+def test_service_spending_agrees_with_existing_ledger_and_repair(strategy):
+ value = game_test.world()
+ for words in strategy:
+ game_test.attempt(value, words)
+ records = value.view()['services']
+ assert sum(row['resolution']['fuel_spent'] for row in records) == (
+ value.inventory_state()['Used']['fuel']
+ )
+ final = record(value, 'beacon', 'Before Dawn')
+ if strategy == game_test.FULL_SERVICE:
+ assert final['served'] and final['demand'] == 0
+ assert final['resolution']['basis'] == 'repair_supplied'
+ assert final['resolution']['fuel_spent'] == 0
+ assert 'repair' in final['resolution']['explanation'].lower()
+ else:
+ assert not final['served']
+ assert final['resolution']['basis'] == 'not_requested'
+
+
+def test_public_export_allowlists_details_and_never_invents_legacy_reason():
+ value = game_test.world()
+ for _ in range(4):
+ game_test.attempt(value, 'wait')
+ value.data['services'][0]['resolution'][
+ 'private_extra'
+ ] = 'PRIVATE_RULE_DETAIL'
+ value.data['services'][1].pop('resolution')
+ projected = public_account.document(value, fixture=True, phase='running')
+ data = projected['accounting']['services']
+ assert set(data[0]['resolution']) == {
+ 'basis',
+ 'priority',
+ 'fuel_before',
+ 'fuel_spent',
+ 'requested',
+ 'explanation',
+ }
+ assert 'resolution' not in data[1]
+ assert 'PRIVATE_RULE_DETAIL' not in json.dumps(projected)
+ rendered = public_account.render_html(projected)
+ assert data[0]['resolution']['explanation'] in rendered
+ assert 'not recorded' in rendered.lower()
+ assert 'PRIVATE_RULE_DETAIL' not in rendered
+
+
+def test_repaired_beacon_can_follow_unfunded_requests_with_zero_stock():
+ value = game_test.world()
+ # Component fixture at final watch; this is not a simulated repair.
+ value.data['watch'] = 2
+ value.data['repair'] = True
+ value.data['allocations'] = ['shelter', 'cold store', 'beacon']
+ value.transfer('Generator', 'Used', 'fuel', 6)
+ value.transfer('Nell', 'Used', 'fuel', 2)
+ value._boundary() # pylint: disable=protected-access
+ final = record(value, 'beacon', 'Before Dawn')
+ assert final['served']
+ assert final['resolution']['basis'] == 'repair_supplied'
+ assert final['resolution']['priority'] == 3
+ assert final['resolution']['fuel_before'] == 0
+ assert final['resolution']['fuel_spent'] == 0
+ assert all(
+ record(value, name, 'Before Dawn')['resolution']['basis']
+ == 'fuel_shortfall'
+ for name in ['shelter', 'cold store']
+ )
+ value.invariant()
diff --git a/concordia/examples/bellwether/browser_test.py b/concordia/examples/bellwether/browser_test.py
new file mode 100644
index 000000000..cd217fea9
--- /dev/null
+++ b/concordia/examples/bellwether/browser_test.py
@@ -0,0 +1,178 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Actual Chromium/CLI parity on real components, guarded against simulation."""
+
+import json
+import pathlib
+import subprocess
+import sys
+from unittest import mock
+import urllib.request
+
+from concordia.examples.bellwether import service
+from concordia.utils import simulation_server
+from concordia.utils import visual_interface
+import pytest
+
+playwright = pytest.importorskip('playwright.sync_api')
+
+
+def test_gui_cli_semantics_reconnect_and_player_privacy(tmp_path):
+ with mock.patch(
+ 'concordia.prefabs.simulation.generic.Simulation.play',
+ side_effect=AssertionError('No run in this test'),
+ ):
+ gui = service.Bellwether(tmp_path / 'gui')
+ cli = service.Bellwether(tmp_path / 'cli')
+ gui.server.set_html_content(
+ visual_interface.visualize_operations_to_html(gui.config)
+ )
+ public = simulation_server.SimulationServer(
+ port=0,
+ html_content=pathlib.Path(__file__)
+ .with_name('player.html')
+ .read_text(encoding='utf-8'),
+ operation_service=gui.operations,
+ audience='player',
+ )
+ gui.server.start()
+ cli.server.start()
+ public.start()
+ try:
+ with playwright.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ page = browser.new_page()
+ player = browser.new_page(viewport={'width': 390, 'height': 844})
+ errors = []
+ page.on('pageerror', lambda error: errors.append(str(error)))
+ player.on('pageerror', lambda error: errors.append(str(error)))
+ page.goto(f'http://127.0.0.1:{gui.server.bound_port}')
+ player.goto(f'http://127.0.0.1:{public.bound_port}')
+ playwright.expect(page.locator('#op-status')).to_contain_text(
+ 'Connected'
+ )
+ playwright.expect(player.locator('#connection')).to_contain_text(
+ 'Connected'
+ )
+ page.locator('#op-snapshot summary').click()
+ page.select_option('#op-preview-field', 'target')
+ page.select_option('#op-name', 'component.edit')
+ value = (
+ 'PRIVATE_EDIT: "quoted"\n &'
+ ' mémoire 🌧️'
+ )
+ page.locator('[data-key=value]').fill(value)
+ with page.expect_request('**/api/dispatch') as captured:
+ page.click('#op-submit')
+ playwright.expect(page.locator('#op-preview-status')).to_contain_text(
+ 'newer'
+ )
+ page.click('#op-preview-refresh')
+ playwright.expect(page.locator('#op-state')).to_contain_text(
+ 'PRIVATE_EDIT'
+ )
+ body = captured.value.post_data_json
+ body['references'] = cli.operations.references
+ result = subprocess.run(
+ [
+ sys.executable,
+ '-m',
+ 'concordia.command_line_interface.concordia_session',
+ '--url',
+ f'http://127.0.0.1:{cli.server.bound_port}',
+ 'call',
+ ],
+ input=json.dumps(body),
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ assert gui.developer_view() == cli.developer_view()
+ assert page.locator('#operations-panel img').count() == 0
+ assert 'PRIVATE_EDIT' not in player.content()
+ before = gui.operations.snapshot('developer')
+ # A second actor changes the service after a GUI draft starts.
+ page.locator('[data-key=value]').fill('keep this stale draft')
+ changed = {
+ **body,
+ 'references': gui.operations.references,
+ 'revision': before['revision'],
+ 'retry_key': 'second',
+ 'arguments': {'value': 'new live text'},
+ }
+ gui.operations.dispatch('developer', changed)
+ playwright.expect(page.locator('#op-preview-status')).to_contain_text(
+ 'newer'
+ )
+ page.click('#op-preview-refresh')
+ playwright.expect(page.locator('#op-state')).to_contain_text(
+ 'new live text'
+ )
+ page.click('#op-submit')
+ playwright.expect(page.locator('#op-error')).to_contain_text(
+ 'State changed'
+ )
+ assert (
+ page.locator('[data-key=value]').input_value()
+ == 'keep this stale draft'
+ )
+ page.reload()
+ playwright.expect(page.locator('#op-status')).to_contain_text(
+ 'Connected'
+ )
+ page.locator('#op-snapshot summary').click()
+ page.select_option('#op-preview-field', 'target')
+ playwright.expect(page.locator('#op-state')).to_contain_text(
+ 'new live text'
+ )
+ # Genuine disconnect/reconnect of the operation stream, not mock SSE.
+ player.route('**/api/events', lambda route: route.abort('failed'))
+ player.reload()
+ playwright.expect(player.locator('#connection')).to_contain_text(
+ 'Reconnecting'
+ )
+ player.unroute('**/api/events')
+ playwright.expect(player.locator('#connection')).to_contain_text(
+ 'Connected', timeout=15000
+ )
+ assert player.evaluate(
+ 'document.documentElement.scrollWidth <= innerWidth'
+ )
+ for endpoint in ('/api/state', '/api/operations', '/'):
+ with urllib.request.urlopen(
+ f'http://127.0.0.1:{public.bound_port}' + endpoint
+ ) as response:
+ assert 'PRIVATE_' not in response.read().decode()
+ # The one-turn service shares this page but has no public exporter.
+ gui.phase = 'failed'
+ gui._failure = 'FixtureRuntimeFailure' # pylint: disable=protected-access
+ gui._finish() # pylint: disable=protected-access
+ gui.operations.publish({'kind': 'test.failed_fixture'})
+ playwright.expect(player.locator('#connection')).to_contain_text(
+ 'Night stopped'
+ )
+ playwright.expect(player.locator('#run-status')).to_be_visible()
+ assert (
+ 'public account' not in player.locator('#run-status').inner_text()
+ )
+ playwright.expect(player.locator('#public-account')).to_be_hidden()
+ assert 'FixtureRuntimeFailure' not in player.content()
+ assert not errors
+ browser.close()
+ finally:
+ gui.close()
+ cli.close()
+ public.stop()
diff --git a/concordia/examples/bellwether/council.py b/concordia/examples/bellwether/council.py
new file mode 100644
index 000000000..d67788f9b
--- /dev/null
+++ b/concordia/examples/bellwether/council.py
@@ -0,0 +1,309 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""One-round resource council using Concordia's standard Simultaneous engine.
+
+This is an analogous teaching game, NOT Bellwether's three-watch game with
+an engine flag changed. Choices explicitly authorize immediate contributions,
+not negotiations or promises. The CLI is a programmed fixture, not live actors.
+"""
+
+import argparse
+from collections.abc import Mapping
+import copy
+import datetime
+import json
+import pathlib
+from typing import Any
+
+from concordia.components.agent import human_act_component
+from concordia.components.game_master import event_resolution
+from concordia.components.game_master import inventory
+from concordia.components.game_master import make_observation
+from concordia.components.game_master import next_acting
+from concordia.components.game_master import switch_act
+from concordia.environment.engines import simultaneous
+from concordia.examples.bellwether import game
+from concordia.language_model import no_language_model
+from concordia.prefabs.entity import minimal
+from concordia.prefabs.simulation import generic
+from concordia.typing import entity as entity_lib
+from concordia.typing import entity_component
+from concordia.typing import prefab
+import numpy as np
+
+MEMBERS = ('Nell', 'Ivo', 'Sam')
+CHOICES = ('contribute', 'keep')
+GM = 'Council'
+INSTRUCTIONS = (
+ 'Each member owns one fuel. Choose contribute to explicitly authorize and'
+ ' execute donation of your own unit to Community after all choices arrive,'
+ ' or keep to retain it. This is one simultaneous round. You cannot see'
+ ' other members’ current choices before making your own. No promise or'
+ ' majority vote can give away another member’s fuel.'
+)
+
+
+class CouncilLedger(
+ entity_component.ContextComponent, entity_component.ComponentWithLogging
+):
+ """Scenario-specific complete-batch validation, not an execution loop."""
+
+ def __init__(self, stock):
+ super().__init__()
+ self.stock = stock
+ self.done = False
+ self.decisions = {}
+ self._putative = ''
+ self.result = ''
+ self._resolved_input = ''
+
+ def get_state(self):
+ return copy.deepcopy({
+ 'done': self.done,
+ 'decisions': self.decisions,
+ 'putative': self._putative,
+ 'result': self.result,
+ 'resolved_input': self._resolved_input,
+ })
+
+ def set_state(self, state):
+ self.done = state['done']
+ self.decisions = copy.deepcopy(state['decisions'])
+ self._putative = state['putative']
+ self.result = state['result']
+ self._resolved_input = state['resolved_input']
+
+ def pre_observe(self, observation):
+ if observation.startswith(event_resolution.PUTATIVE_EVENT_TAG):
+ self._putative = observation[
+ len(event_resolution.PUTATIVE_EVENT_TAG) :
+ ].strip()
+ return ''
+
+ def pre_act(self, action_spec):
+ if action_spec.output_type != entity_lib.OutputType.RESOLVE:
+ return ''
+ result = self.resolve_batch(self._putative)
+ self._logging_channel({
+ 'Key': 'CouncilLedger',
+ 'Value': result,
+ 'State': self.get_state(),
+ 'Inventory': self.stock.get_state(),
+ })
+ return result
+
+ def resolve_batch(self, text):
+ if self.done:
+ if text != self._resolved_input:
+ raise ValueError('The council round has already resolved')
+ return self.result
+ decisions = {}
+ for line in text.splitlines():
+ name, separator, choice = line.partition(': ')
+ if (
+ not separator
+ or name not in MEMBERS
+ or name in decisions
+ or choice not in CHOICES
+ ):
+ raise ValueError('Invalid council batch; no fuel transferred')
+ decisions[name] = choice
+ if set(decisions) != set(MEMBERS):
+ raise ValueError('Incomplete council batch; no fuel transferred')
+
+ def transfer(accounts):
+ for name, choice in decisions.items():
+ if choice == 'contribute':
+ accounts[name]['fuel'] -= 1
+ accounts['Community']['fuel'] += 1
+ return accounts
+
+ # A single standard validated material transaction after the whole batch.
+ self.stock.apply(transfer)
+ self.decisions = decisions
+ self._resolved_input = text
+ self.done = True
+ count = self.stock.get_player_inventory('Community')['fuel']
+ self.result = (
+ f'{count} of 3 units explicitly contributed. '
+ + '; '.join(f'{name}: {decisions[name]}' for name in MEMBERS)
+ + '. No consent inferred from another member’s choice.'
+ )
+ return self.result
+
+
+def configuration(*, human_readers=None):
+ """Fresh Config and ledger; optional separately routed human readers."""
+ readers = {} if human_readers is None else human_readers
+ if not isinstance(readers, Mapping) or any(
+ name not in MEMBERS or not callable(reader)
+ for name, reader in readers.items()
+ ):
+ raise ValueError(
+ 'Human readers must be callables for known council members'
+ )
+ stock = game.ExplicitInventory(
+ no_language_model.NoLanguageModel(),
+ [
+ inventory.ItemTypeConfig(
+ 'fuel', minimum=0, maximum=3, force_integer=True
+ )
+ ],
+ {**{name: {'fuel': 1.0} for name in MEMBERS}, 'Community': {'fuel': 0.0}},
+ lambda: datetime.datetime(2000, 1, 1),
+ )
+ ledger = CouncilLedger(stock)
+ observations = make_observation.ObservationQueue()
+ for name in MEMBERS:
+ observations.add(name, INSTRUCTIONS, MEMBERS)
+
+ class Member(prefab.Prefab):
+ description = (
+ 'Standard minimal actor, optionally using a human input adapter.'
+ )
+
+ def build(self, model, memory_bank):
+ params: dict[str, Any] = dict(self.params)
+ reader = readers.get(params['name'])
+ return minimal.Entity(params=params).build(
+ model,
+ memory_bank,
+ act_component_factory=(
+ (
+ lambda order: human_act_component.HumanActComponent(
+ reader, component_order=order
+ )
+ )
+ if reader is not None
+ else None
+ ),
+ )
+
+ class Moderator(prefab.Prefab):
+ """Build existing GM routing and scenario accounting components."""
+
+ description = (
+ 'Standard SwitchAct routing for one simultaneous council round.'
+ )
+
+ def build(self, model, memory_bank):
+ components = {
+ switch_act.DEFAULT_RESOLUTION_COMPONENT_KEY: ledger,
+ 'Inventory': stock,
+ switch_act.DEFAULT_NEXT_ACTING_COMPONENT_KEY: (
+ next_acting.NextActingAllEntities(MEMBERS)
+ ),
+ switch_act.DEFAULT_NEXT_ACTION_SPEC_COMPONENT_KEY: (
+ next_acting.FixedActionSpec(
+ entity_lib.choice_action_spec(
+ call_to_action='{name}, contribute your unit or keep it?',
+ options=CHOICES,
+ )
+ )
+ ),
+ switch_act.DEFAULT_TERMINATE_COMPONENT_KEY: game.Signal(
+ ledger,
+ entity_lib.OutputType.TERMINATE,
+ lambda: 'Yes' if ledger.done else 'No',
+ ),
+ switch_act.DEFAULT_MAKE_OBSERVATION_COMPONENT_KEY: (
+ make_observation.MakeObservation(
+ model,
+ player_names=MEMBERS,
+ allow_llm_fallback=False,
+ external_queue=observations,
+ )
+ ),
+ }
+ return minimal.Entity(
+ params={
+ 'name': GM,
+ 'custom_instructions': INSTRUCTIONS,
+ 'extra_components': components, # pyrefly: ignore[bad-assignment]
+ }
+ ).build(
+ model,
+ memory_bank,
+ act_component=switch_act.SwitchAct(model, entity_names=MEMBERS),
+ )
+
+ instances = []
+ for name in MEMBERS:
+ params: dict[str, Any] = {
+ 'name': name,
+ 'custom_instructions': INSTRUCTIONS,
+ 'randomize_choices': False,
+ }
+ instances.append(
+ prefab.InstanceConfig(
+ prefab='member', role=prefab.Role.ENTITY, params=params
+ )
+ )
+ instances.append(
+ prefab.InstanceConfig(
+ prefab='moderator', role=prefab.Role.GAME_MASTER, params={'name': GM}
+ )
+ )
+ config = prefab.Config(
+ prefabs={'member': Member(), 'moderator': Moderator()},
+ instances=instances,
+ default_max_steps=1,
+ )
+ return config, ledger
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ '--output',
+ type=pathlib.Path,
+ default=pathlib.Path('runs/council-fixture'),
+ )
+ args = parser.parse_args(argv)
+ config, ledger = configuration()
+ simulation = generic.Simulation(
+ config,
+ no_language_model.NoLanguageModel(),
+ lambda text: np.zeros(8),
+ engine=simultaneous.Simultaneous(),
+ )
+ log = simulation.play(max_steps=1)
+ args.output.mkdir(parents=True, exist_ok=True)
+ (args.output / 'trace.json').write_text(log.to_json(), encoding='utf-8')
+ (args.output / 'trace.html').write_text(log.to_html(), encoding='utf-8')
+ (args.output / 'outcome.json').write_text(
+ json.dumps(
+ {
+ 'fixture': True,
+ 'external_model_calls': 0,
+ 'engine': 'Simultaneous',
+ 'complete': ledger.done,
+ 'rounds': len(simulation.get_raw_log()),
+ 'decisions': ledger.decisions,
+ 'result': ledger.result,
+ 'inventory': {
+ name: ledger.stock.get_player_inventory(name)
+ for name in (*MEMBERS, 'Community')
+ },
+ },
+ indent=2,
+ ),
+ encoding='utf-8',
+ )
+ print(ledger.result)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/concordia/examples/bellwether/council_test.py b/concordia/examples/bellwether/council_test.py
new file mode 100644
index 000000000..2dc0a2d9e
--- /dev/null
+++ b/concordia/examples/bellwether/council_test.py
@@ -0,0 +1,128 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit checks of standard batch contracts, not live simulations."""
+
+import copy
+from itertools import permutations
+from unittest import mock
+
+from concordia.agents import entity_agent
+from concordia.components.game_master import event_resolution
+from concordia.environment.engines import simultaneous
+from concordia.examples.bellwether import council
+from concordia.language_model import no_language_model
+from concordia.prefabs.simulation import generic
+from concordia.typing import entity as entity_lib
+import numpy as np
+import pytest
+
+
+def balances(ledger):
+ return {
+ name: ledger.stock.get_player_inventory(name)
+ for name in (*council.MEMBERS, 'Community')
+ }
+
+
+@pytest.mark.parametrize('order', list(permutations(council.MEMBERS)))
+def test_complete_batch_order_independence_and_retry(order):
+ _, ledger = council.configuration()
+ choices = {'Nell': 'contribute', 'Ivo': 'keep', 'Sam': 'contribute'}
+ raw = '\n'.join(f'{name}: {choices[name]}' for name in order)
+ result = ledger.resolve_batch(raw)
+ assert balances(ledger) == {
+ 'Nell': {'fuel': 0},
+ 'Ivo': {'fuel': 1},
+ 'Sam': {'fuel': 0},
+ 'Community': {'fuel': 2},
+ }
+ assert ledger.resolve_batch(raw) == result
+ assert sum(x['fuel'] for x in balances(ledger).values()) == 3
+ before = copy.deepcopy(balances(ledger))
+ ledger.pre_observe(event_resolution.PUTATIVE_EVENT_TAG + ' Nell: keep')
+ with pytest.raises(ValueError):
+ ledger.pre_act(
+ entity_lib.ActionSpec(
+ call_to_action='Resolve', output_type=entity_lib.OutputType.RESOLVE
+ )
+ )
+ assert balances(ledger) == before
+
+
+@pytest.mark.parametrize(
+ 'raw',
+ [
+ '',
+ 'Nell: contribute\nIvo: keep',
+ 'Nell: contribute\nIvo: keep\nNell: contribute',
+ 'Nell: contribute\nIvo: keep\nUnknown: contribute',
+ 'Nell: speak for everyone\nIvo: keep\nSam: contribute',
+ ],
+)
+def test_incomplete_or_invalid_batch_never_transfers_partially(raw):
+ _, ledger = council.configuration()
+ before = (ledger.get_state(), balances(ledger))
+ with pytest.raises(ValueError):
+ ledger.resolve_batch(raw)
+ assert (ledger.get_state(), balances(ledger)) == before
+
+
+def test_actual_standard_engine_selects_three_specs_before_resolution():
+ config, ledger = council.configuration(
+ human_readers={'Nell': lambda request: 'keep'}
+ )
+ engine = simultaneous.Simultaneous()
+ with mock.patch.object(
+ generic.Simulation, 'play', side_effect=AssertionError('No launch')
+ ):
+ sim = generic.Simulation(
+ config,
+ no_language_model.NoLanguageModel(),
+ lambda text: np.zeros(8),
+ engine=engine,
+ )
+ actors, specs = engine.next_acting(
+ sim.get_game_masters()[0], sim.get_entities()
+ )
+ assert [a.name for a in actors] == list(council.MEMBERS)
+ assert all(
+ spec.output_type == entity_lib.OutputType.CHOICE
+ and spec.options == council.CHOICES
+ for spec in specs
+ )
+ for actor in actors:
+ assert isinstance(actor, entity_agent.EntityAgent)
+ assert type(actor.get_act_component()).__name__ == (
+ 'HumanActComponent' if actor.name == 'Nell' else 'ConcatActComponent'
+ )
+ assert ledger.done is False
+ assert balances(ledger)['Community']['fuel'] == 0
+
+
+def test_fresh_inventory_components_and_state_roundtrip():
+ config, one = council.configuration()
+ other_config, two = council.configuration()
+ assert config is not other_config and one.stock is not two.stock
+ one.resolve_batch('Nell: keep\nIvo: contribute\nSam: keep')
+ assert balances(two)['Ivo']['fuel'] == 1
+ two.set_state(one.get_state())
+ assert two.get_state() == one.get_state()
+ # Component serialization alone does not restore inventory or the engine.
+ assert balances(two)['Community']['fuel'] == 0
+
+
+def test_unknown_human_role_rejected():
+ with pytest.raises(ValueError):
+ council.configuration(human_readers={'Coordinator': lambda r: 'keep'})
diff --git a/concordia/examples/bellwether/developer_snapshot_browser_test.py b/concordia/examples/bellwether/developer_snapshot_browser_test.py
new file mode 100644
index 000000000..94ad1c112
--- /dev/null
+++ b/concordia/examples/bellwether/developer_snapshot_browser_test.py
@@ -0,0 +1,176 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bounded developer rendering on real server state; no simulation or model."""
+
+import json
+from unittest import mock
+
+from concordia.examples.bellwether import service
+from concordia.prefabs.simulation import generic
+from concordia.utils import visual_interface
+import pytest
+
+pwlib = pytest.importorskip('playwright.sync_api')
+
+
+def test_large_received_snapshot_is_lazy_bounded_and_downloadable(tmp_path):
+ with mock.patch.object(
+ generic.Simulation, 'play', side_effect=AssertionError('No simulation')
+ ):
+ host = service.Bellwether(tmp_path / 'host')
+ original_view = host.developer_view
+ payload = {
+ 'fixture_version': 0,
+ 'fixture_big_integer': 9223372036854775783,
+ 'fixture_large_history': (
+ ['literal & 🌊' * 30] * 400
+ ),
+ }
+ host.operations.set_view(
+ 'developer', lambda: {**payload, **original_view()}
+ )
+ host.server.set_html_content(
+ visual_interface.visualize_operations_to_html(host.config)
+ )
+ host.server.start()
+ try:
+ with pwlib.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ try:
+ page = browser.new_page(viewport={'width': 1200, 'height': 850})
+ errors = []
+ page.on('pageerror', lambda e: errors.append(str(e)))
+ page.add_init_script("""
+ window.fullStringifies=0;const stringify=JSON.stringify;
+ JSON.stringify=function(value,...args){
+ if(value?.result?.fixture_large_history?.length>100)
+ window.fullStringifies++;
+ return stringify.call(JSON,value,...args);
+ };
+ """)
+ page.goto(f'http://127.0.0.1:{host.server.bound_port}')
+ pwlib.expect(page.locator('#op-status')).to_contain_text('Connected')
+ # Baseline eagerly inserted a large text block on every SSE.
+ assert len(page.locator('#op-state').inner_text()) == 0
+ assert page.evaluate('window.fullStringifies') == 0
+ page.select_option('#op-name', 'component.edit')
+ page.locator('[data-key=value]').fill(
+ 'Keep this unsent developer draft'
+ )
+ page.locator('[data-key=value]').evaluate(
+ 'e=>e.setSelectionRange(5,9)'
+ )
+ initial = host.operations.snapshot('developer')
+ page.locator('#op-snapshot summary').click()
+ pwlib.expect(page.locator('#op-preview-status')).to_contain_text(
+ 'abbreviated'
+ )
+ preview = page.locator('#op-state').inner_text()
+ assert len(preview) < 30000
+ assert '"fixture_version": 0' in preview
+ assert '[preview:' in preview
+ assert 'number outside safe integer range' in preview
+ assert page.locator('#operations-panel img').count() == 0
+ # Refresh is explicit; inbound SSE must not rewrite text being read.
+ payload['fixture_version'] = 1
+ host.operations.publish({'kind': 'fixture.updated'})
+ pwlib.expect(page.locator('#op-preview-status')).to_contain_text(
+ 'newer'
+ )
+ assert page.locator('#op-state').inner_text() == preview
+ assert (
+ page.locator('[data-key=value]').input_value()
+ == 'Keep this unsent developer draft'
+ )
+ assert page.locator('[data-key=value]').evaluate(
+ 'e=>[e.selectionStart,e.selectionEnd]'
+ ) == [5, 9]
+ page.click('#op-preview-refresh')
+ pwlib.expect(page.locator('#op-state')).to_contain_text(
+ '"fixture_version": 1'
+ )
+ assert page.evaluate('window.fullStringifies') == 0
+ page.select_option('#op-preview-field', 'fixture_large_history')
+ pwlib.expect(page.locator('#op-state')).to_contain_text('[preview:')
+ assert len(page.locator('#op-state').inner_text()) < 30000
+ assert page.locator('#operations-panel img').count() == 0
+ with page.expect_download() as download:
+ page.click('#op-snapshot-download')
+ path = download.value.path()
+ downloaded = json.loads(path.read_text())
+ current = json.loads(
+ json.dumps(host.operations.snapshot('developer'))
+ )
+
+ assert downloaded == current
+ assert (
+ downloaded['result']['fixture_big_integer'] == 9223372036854775783
+ )
+ assert page.evaluate('window.fullStringifies') == 0
+ assert (
+ downloaded['result']['fixture_large_history']
+ == payload['fixture_large_history']
+ )
+ assert downloaded['revision'] > initial['revision']
+ # Inspection/download did not silently send or repair a stale edit.
+ page.click('#op-submit')
+ pwlib.expect(page.locator('#op-error')).to_contain_text(
+ 'State changed'
+ )
+ assert (
+ page.locator('[data-key=value]').input_value()
+ == 'Keep this unsent developer draft'
+ )
+ page.screenshot(
+ path=str(tmp_path / 'bounded-developer.png'), timeout=5000
+ )
+ page.reload()
+ pwlib.expect(page.locator('#op-status')).to_contain_text('Connected')
+ assert page.locator('#op-state').inner_text() == ''
+ page.route('**/api/events', lambda route: route.abort('failed'))
+ page.reload()
+ pwlib.expect(page.locator('#op-status')).to_contain_text(
+ 'reconnecting'
+ )
+ pwlib.expect(page.locator('#op-snapshot-download')).to_be_disabled()
+ page.unroute('**/api/events')
+ pwlib.expect(page.locator('#op-status')).to_contain_text(
+ 'Connected', timeout=15000
+ )
+ pwlib.expect(page.locator('#op-snapshot-download')).to_be_enabled()
+ assert page.locator('#op-state').inner_text() == ''
+ assert not errors
+ (tmp_path / 'browser-evidence.json').write_text(
+ json.dumps(
+ {
+ 'automated': True,
+ 'preview_characters': len(preview),
+ 'download_bytes': path.stat().st_size,
+ 'whole_snapshot_stringifications': page.evaluate(
+ 'window.fullStringifies'
+ ),
+ 'exact_download_and_large_integer': True,
+ 'errors': errors,
+ 'simulation_steps': 0,
+ 'provider_calls': 0,
+ 'physical_device': False,
+ },
+ indent=2,
+ )
+ )
+ finally:
+ browser.close()
+ finally:
+ host.close()
diff --git a/concordia/examples/bellwether/display_labels_browser_test.py b/concordia/examples/bellwether/display_labels_browser_test.py
new file mode 100644
index 000000000..c691f581b
--- /dev/null
+++ b/concordia/examples/bellwether/display_labels_browser_test.py
@@ -0,0 +1,147 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Requested display names must never change role ownership or records."""
+
+from concordia.examples.bellwether.focus_browser_test import host_fixture # pylint: disable=unused-import
+from concordia.examples.bellwether.focus_browser_test import publish
+from concordia.examples.bellwether.focus_browser_test import track_snapshots
+from concordia.examples.bellwether.visual_note_browser_test import pending
+import pytest
+
+pw = pytest.importorskip('playwright.sync_api')
+
+
+@pytest.mark.parametrize('width,height', [(360, 800), (800, 360)])
+def test_person_watch_and_literal_records_remain_distinct(
+ host, tmp_path, width, height
+):
+ value, url = host()
+ results, thread = pending(value.inbox)
+ literal = 'Coordinator said “Dusk” in the recorded message.'
+ value.world.emit('speech', literal)
+ with pw.sync_playwright() as runner:
+ browser = runner.chromium.launch()
+ try:
+ page = browser.new_page(viewport={'width': width, 'height': height})
+ track_snapshots(page)
+ errors, posts = [], []
+ page.on('pageerror', lambda e: errors.append(str(e)))
+ page.on(
+ 'request',
+ lambda r: posts.append(r.url) if r.method == 'POST' else None,
+ )
+ page.goto(url)
+ pw.expect(page.locator('#act')).to_be_enabled()
+ before = value.operations.snapshot('developer')
+ pw.expect(page.locator('#role-badge')).to_contain_text(
+ 'Coordinator Alice'
+ )
+ pw.expect(page.locator('#watch')).to_have_text(
+ 'Watch 1 · Early evening · 4 choices remain'
+ )
+ assert value.world.view()['watch'] == 'Dusk'
+ assert value.world.view()['next_actor'] == 'Coordinator'
+ pw.expect(page.locator('#journal')).to_contain_text(literal)
+ assert page.locator('#context').text_content() == (
+ value.inbox.snapshot()['pending']['context']
+ )
+ page.fill('#action', 'Keep my Coordinator draft at Dusk')
+ assert any(
+ key.endswith(':Coordinator')
+ for key in page.evaluate('Object.keys(sessionStorage)')
+ )
+ publish(value, page)
+ page.reload()
+ pw.expect(page.locator('#act')).to_be_enabled()
+ assert (
+ page.locator('#action').input_value()
+ == 'Keep my Coordinator draft at Dusk'
+ )
+ pw.expect(page.locator('#role-badge')).to_contain_text(
+ 'Coordinator Alice'
+ )
+ assert value.world.view()['watch'] == 'Dusk'
+ assert not results and not posts and not errors
+ # The test publish increments only service revision, not world ownership.
+ assert (
+ value.operations.snapshot('developer')['result']['night']
+ == before['result']['night']
+ )
+ page.screenshot(
+ path=str(tmp_path / 'clear-player-watch.png'), full_page=True
+ )
+ assert page.evaluate('document.documentElement.scrollWidth') <= width
+ for watch in ('High Tide', 'Before Dawn'):
+ value.world.data['watch'] = {'High Tide': 1, 'Before Dawn': 2}[watch]
+ publish(value, page)
+ pw.expect(page.locator('#watch')).to_have_text(
+ watch + ' · 4 choices remain'
+ )
+ finally:
+ value.inbox.finish('Test fixture closed')
+ thread.join(2)
+ browser.close()
+
+
+def test_join_display_label_preserves_authenticated_roles(host):
+ value, url = host(shared=True)
+ with pw.sync_playwright() as runner:
+ browser = runner.chromium.launch()
+ try:
+ for role, label in [
+ ('Coordinator', 'Coordinator Alice'),
+ ('Nell', 'Nell'),
+ ('spectator', 'Spectator'),
+ ]:
+ context = browser.new_context(viewport={'width': 360, 'height': 800})
+ page = context.new_page()
+ track_snapshots(page)
+ page.goto(url)
+ pw.expect(
+ page.locator('#join-role option[value=Coordinator]')
+ ).to_have_text('Coordinator Alice')
+ page.fill('#join-name', 'Role fixture ' + role)
+ page.select_option('#join-role', role)
+ page.click('#join-submit')
+ pw.expect(page.locator('#join-status')).to_contain_text(
+ 'Waiting for host approval'
+ )
+ request = next(
+ x
+ for x in value.sessions.pending()
+ if x['label'] == 'Role fixture ' + role
+ )
+ assert request['requested_role'] == role
+ value.sessions.approve(request['id'])
+ publish(value, page)
+ pw.expect(page.locator('#join')).to_be_hidden()
+ pw.expect(page.locator('#role-badge')).to_contain_text(label)
+ assert (
+ value.operations.snapshot(request['id'])['result']['role'] == role
+ )
+ if role != 'Coordinator':
+ assert (
+ 'Coordinator Alice'
+ not in page.locator('#role-badge').text_content()
+ )
+ page.reload()
+ pw.expect(page.locator('#role-badge')).to_contain_text(label)
+ assert (
+ value.operations.snapshot(request['id'])['result']['role'] == role
+ )
+ assert value.world.view()['watch'] == 'Dusk'
+ assert value.world.view()['next_actor'] == 'Coordinator'
+ finally:
+ browser.close()
diff --git a/concordia/examples/bellwether/facility_browser_test.py b/concordia/examples/bellwether/facility_browser_test.py
new file mode 100644
index 000000000..2299625a2
--- /dev/null
+++ b/concordia/examples/bellwether/facility_browser_test.py
@@ -0,0 +1,128 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Map/history accessibility using component fixtures, without an engine."""
+
+import pathlib
+from unittest import mock
+
+from concordia.examples.bellwether import game as rules
+from concordia.examples.bellwether import game_service
+from concordia.utils import simulation_server
+import pytest
+
+pwlib = pytest.importorskip('playwright.sync_api')
+
+
+@pytest.mark.parametrize(
+ ('width', 'height', 'forced'),
+ [(360, 800, 'none'), (800, 360, 'none'), (360, 800, 'active')],
+)
+def test_map_status_history_and_free_inspection(
+ tmp_path, width, height, forced
+):
+ with mock.patch(
+ 'concordia.prefabs.simulation.generic.Simulation.play',
+ side_effect=AssertionError('Component fixtures only; no simulation'),
+ ):
+ game = game_service.Game(tmp_path)
+ server = simulation_server.SimulationServer(
+ port=0,
+ operation_service=game.operations,
+ audience='player',
+ html_content=pathlib.Path(__file__)
+ .with_name('player.html')
+ .read_text(encoding='utf-8'),
+ )
+ server.start()
+ try:
+ with pwlib.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ try:
+ page = browser.new_page(
+ viewport={'width': width, 'height': height},
+ has_touch=True,
+ forced_colors=forced,
+ )
+ errors, mutations = [], []
+ page.on('pageerror', lambda e: errors.append(str(e)))
+ page.on(
+ 'request',
+ lambda r: mutations.append(r.url) if r.method == 'POST' else None,
+ )
+ page.goto(f'http://127.0.0.1:{server.bound_port}')
+ beacon = page.locator('#map [data-location="Harbor beacon"]')
+ pwlib.expect(beacon).to_contain_text('Not resolved yet')
+ assert not beacon.evaluate("e=>e.classList.contains('unserved')")
+ page.locator('#service-history summary').click()
+ assert page.locator('#service-head th').all_text_contents() == [
+ 'Watch',
+ 'Beacon',
+ 'Shelter',
+ 'Cold store',
+ ]
+ assert page.locator('#service-rows td').all_text_contents() == (
+ ['Not resolved'] * 9
+ )
+ # Pure scenario-component resolution supplies real accounting data
+ # to the existing view. No entity.act/Simulation.play/model is run.
+ game.world.resolve(rules.PLAYER, 'allocate shelter and cold store')
+ for _ in range(3):
+ game.world.resolve(rules.PLAYER, 'wait')
+ game.operations.publish({'kind': 'test.resolved_watch_fixture'})
+ pwlib.expect(beacon).to_contain_text(
+ 'Watch 1 · Early evening · Unserved'
+ )
+ assert beacon.evaluate("e=>e.classList.contains('unserved')")
+ assert not beacon.evaluate("e=>e.classList.contains('lit')")
+ shelter = page.locator('#map [data-location="Storm shelter"]')
+ pwlib.expect(shelter).to_contain_text(
+ 'Watch 1 · Early evening · Maintained'
+ )
+ assert (
+ 'Watch 1 · Early evening · Maintained'
+ in shelter.get_attribute('aria-label')
+ )
+ pwlib.expect(
+ page.locator('#map [data-location="Generator yard"]')
+ ).to_contain_text('4 fuel available')
+ assert page.locator('#service-rows td').all_text_contents() == [
+ 'Unserved',
+ 'Served',
+ 'Served',
+ *(['Not resolved'] * 6),
+ ]
+ assert page.locator('#service-rows th[scope=row]').count() == 3
+ assert page.locator('#service-head th[scope=col]').count() == 4
+ before = game.operations.snapshot('developer')
+ for button in page.locator('#map button').all():
+ button.focus()
+ page.keyboard.press('Enter')
+ assert button.get_attribute('aria-pressed') == 'true'
+ assert game.operations.snapshot('developer') == before
+ assert not mutations
+ assert 'PRIVATE_NELL' not in page.content()
+ assert page.evaluate('innerWidth') == width
+ assert page.evaluate('document.documentElement.scrollWidth') <= width
+ page.screenshot(
+ path=str(tmp_path / 'service-history.png'), full_page=True
+ )
+ page.add_style_tag(content=':root{font-size:32px}')
+ assert page.evaluate('document.documentElement.scrollWidth') <= width
+ assert not errors
+ finally:
+ browser.close()
+ finally:
+ game.close()
+ server.stop()
diff --git a/concordia/examples/bellwether/focus_browser_test.py b/concordia/examples/bellwether/focus_browser_test.py
new file mode 100644
index 000000000..8e406a263
--- /dev/null
+++ b/concordia/examples/bellwether/focus_browser_test.py
@@ -0,0 +1,197 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Native focus, selection and role clearing through real scoped SSE updates."""
+
+import pathlib
+from unittest import mock
+
+from concordia.examples.bellwether import game_service
+from concordia.examples.bellwether import multiplayer
+from concordia.prefabs.simulation import generic
+from concordia.utils import simulation_server
+import pytest
+
+pwlib = pytest.importorskip('playwright.sync_api')
+
+
+@pytest.fixture(name='host')
+def host_fixture(tmp_path):
+ owned = []
+
+ def create(shared=False):
+ game = (
+ multiplayer.SharedGame(tmp_path, secure=False)
+ if shared
+ else game_service.Game(tmp_path)
+ )
+ server = simulation_server.SimulationServer(
+ port=0,
+ operation_service=game.operations,
+ audience='player',
+ browser_sessions=game.sessions
+ if isinstance(game, multiplayer.SharedGame)
+ else None,
+ html_content=pathlib.Path(__file__)
+ .with_name('player.html')
+ .read_text(encoding='utf-8'),
+ )
+ server.start()
+ owned.append((game, server))
+ return game, f'http://127.0.0.1:{server.bound_port}'
+
+ with mock.patch.object(
+ generic.Simulation, 'play', side_effect=AssertionError('No simulation')
+ ):
+ try:
+ yield create
+ finally:
+ for game, server in owned:
+ game.close()
+ server.stop()
+
+
+def track_snapshots(page):
+ page.add_init_script("""
+ window.snapshotCount=0;
+ const Original=window.EventSource;
+ window.EventSource=class extends Original {
+ constructor(...args){super(...args);this.addEventListener('message',()=>window.snapshotCount++);}
+ };
+ """)
+
+
+def publish(game, page):
+ count = page.evaluate('window.snapshotCount')
+ game.operations.publish({'kind': 'test.unchanged_snapshot'})
+ page.wait_for_function('count=>window.snapshotCount>count', arg=count)
+
+
+@pytest.mark.parametrize('width,height', [(360, 800), (800, 360)])
+def test_controls_and_reading_selection_survive_real_updates(
+ host, width, height
+):
+ game, url = host()
+ with pwlib.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ try:
+ page = browser.new_page(
+ viewport={'width': width, 'height': height}, has_touch=True
+ )
+ track_snapshots(page)
+ errors, mutations = [], []
+ page.on('pageerror', lambda error: errors.append(str(error)))
+ page.on(
+ 'request',
+ lambda request: mutations.append(request.url)
+ if request.method == 'POST'
+ else None,
+ )
+ page.goto(url)
+ pwlib.expect(page.locator('#cast button').first).to_be_visible()
+ before = game.world.get_state()
+ for locator in ['#cast button', '#choices button']:
+ control = page.locator(locator).first
+ control.focus()
+ publish(game, page)
+ pwlib.expect(control).to_be_focused()
+ # Native Enter still edits the draft, not a sent action.
+ page.keyboard.press('Enter')
+ pwlib.expect(page.locator('#action')).to_be_focused()
+ assert page.locator('#action').input_value()
+ page.fill('#action', 'My careful draft')
+ page.locator('#action').evaluate('e=>e.setSelectionRange(3,10)')
+ publish(game, page)
+ assert page.locator('#action').evaluate(
+ 'e=>[e.selectionStart,e.selectionEnd]'
+ ) == [3, 10]
+ assert page.locator('#action').input_value() == 'My careful draft'
+ assert game.world.get_state() == before
+ page.evaluate("""()=>{
+ const span=document.querySelector('#journal p span');
+ window.firstStory=span;const range=document.createRange();
+ range.selectNodeContents(span);const selection=getSelection();
+ selection.removeAllRanges();selection.addRange(range);
+ window.selectedStory=selection.toString();
+ }""")
+ publish(game, page)
+ assert page.evaluate('getSelection().toString()===window.selectedStory')
+ game.world.emit('speech', 'New public fixture note; no turn was played.')
+ publish(game, page)
+ pwlib.expect(page.locator('#journal')).to_contain_text(
+ 'New public fixture note'
+ )
+ assert page.evaluate('getSelection().toString()===window.selectedStory')
+ assert page.evaluate('window.firstStory.isConnected')
+ assert not mutations
+ assert not errors
+ assert page.evaluate('document.documentElement.scrollWidth') <= width
+ # Changed filtering and completion still replace unavailable controls.
+ page.locator('#map [data-location="Generator yard"]').click()
+ assert page.locator('#cast article').count() == 1
+ game.inbox.finish('Test completion fixture')
+ publish(game, page)
+ assert page.locator('#cast button').count() == 0
+ assert page.locator('#choices button').count() == 0
+ finally:
+ browser.close()
+
+
+def test_role_revocation_clears_retained_journal_and_controls(host):
+ game, url = host(shared=True)
+ game.world.emit('private_message', 'PRIVATE_FOCUS_SENTINEL', ['Coordinator'])
+ with pwlib.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ try:
+ page = browser.new_page(viewport={'width': 360, 'height': 800})
+ track_snapshots(page)
+ page.goto(url)
+
+ def approve(role):
+ page.fill('#join-name', 'Test ' + role)
+ page.select_option('#join-role', role)
+ page.click('#join-submit')
+ pwlib.expect(page.locator('#join-status')).to_contain_text(
+ 'Waiting for host'
+ )
+ row = next(
+ row
+ for row in game.sessions.pending()
+ if row['label'] == 'Test ' + role
+ )
+ game.sessions.approve(row['id'])
+ publish(game, page)
+ pwlib.expect(page.locator('#join')).to_be_hidden()
+ return row['id']
+
+ principal = approve('Coordinator')
+ pwlib.expect(page.locator('#journal')).to_contain_text(
+ 'PRIVATE_FOCUS_SENTINEL'
+ )
+ page.fill('#action', 'Private draft')
+ game.sessions.revoke(principal)
+ publish(game, page)
+ pwlib.expect(page.locator('#join')).to_be_visible()
+ assert 'PRIVATE_FOCUS_SENTINEL' not in page.content()
+ assert page.locator('#action').input_value() == ''
+ approve('spectator')
+ assert 'PRIVATE_FOCUS_SENTINEL' not in page.content()
+ assert not page.locator('#composer').is_visible()
+ assert page.locator('#cast button').count() == 0
+ assert page.locator('#choices button').count() == 0
+ pwlib.expect(page.locator('#journal')).to_contain_text(
+ 'Rain blows sideways'
+ )
+ finally:
+ browser.close()
diff --git a/concordia/examples/bellwether/game.py b/concordia/examples/bellwether/game.py
new file mode 100644
index 000000000..ba54ca1ba
--- /dev/null
+++ b/concordia/examples/bellwether/game.py
@@ -0,0 +1,873 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bellwether's fictional rules, not a replacement simulation engine.
+
+Sequential selects actors and invokes the GM. This component resolves only the
+documented actions. Dialogue is never parsed as proof of resources or consent.
+Inventory.apply owns all material balances; completed consumption is retained.
+"""
+
+import copy
+import datetime
+import json
+import re
+import threading
+from typing import Any
+
+from concordia.components.game_master import event_resolution
+from concordia.components.game_master import inventory
+from concordia.examples.bellwether import scenario
+from concordia.language_model import no_language_model
+from concordia.typing import entity as entity_lib
+from concordia.typing import entity_component
+from concordia.utils import operation_service as ops
+
+PLAYER = scenario.PLAYER
+NAMES = [PLAYER, *scenario.RESIDENTS]
+FACILITIES = ('beacon', 'shelter', 'cold store')
+WATCHES = ('Dusk', 'High Tide', 'Before Dawn')
+WORLD_KEY = 'StormNight'
+GM = 'Bellwether'
+OPENING = (
+ 'Dusk. Rain blows sideways over Bellwether. Six fuel units remain in the'
+ ' generator; Nell holds two reserve units and the one spare part. The'
+ ' beacon, shelter and cold store each need one unit per watch: nine across'
+ ' the night. Ivo can remove the final beacon demand if he accepts AND'
+ ' performs a repair with the spare part before Before Dawn. Four decisions'
+ ' remain this watch. You may ask, bargain, allocate, promise, transfer with'
+ ' permission, order accepted work, or wait. Looking around costs nothing.'
+ ' Consent is not automatic.'
+)
+CONSEQUENCES = {
+ 'beacon': 'Boats lose a safe harbor signal; arrivals must hold offshore.',
+ 'shelter': 'Shelter heating fails; displaced residents face a cold watch.',
+ 'cold store': (
+ 'The cooperative cold chain is interrupted; stock is at risk.'
+ ),
+}
+DEFAULT_DISPUTE = {
+ 'text': (
+ 'At High Tide, Mara says the cooperative failed the harbor last storm. '
+ 'Nell disputes this: the spare boat was carrying shelter residents. '
+ 'Neither account is established as objective fact.'
+ ),
+ 'recipients': NAMES,
+}
+INSTITUTIONS = [
+ {
+ 'name': 'Harbor Office',
+ 'members': ['Mara'],
+ 'rule': (
+ 'Protect safe boat passage; advise the coordinator on beacon use.'
+ ),
+ 'enforcement': (
+ 'Advice and objections, not seizure of cooperative property.'
+ ),
+ },
+ {
+ 'name': 'Workers’ Cooperative',
+ 'members': ['Nell', 'Ivo', 'Sam'],
+ 'rule': (
+ 'Nell is delegated custody of reserve and parts; Ivo owns his'
+ ' labor. Members may dispute or organize to revise these'
+ ' arrangements.'
+ ),
+ 'enforcement': (
+ 'Owner consent gates transfers and work; membership is not consent.'
+ ),
+ },
+]
+
+
+class ExplicitInventory(inventory.Inventory):
+ """Use standard Inventory storage/validation without LLM-implied transfers."""
+
+ def pre_act(self, action_spec):
+ del action_spec
+ return ''
+
+
+def new_inventory():
+ return ExplicitInventory(
+ no_language_model.NoLanguageModel(),
+ [
+ inventory.ItemTypeConfig(
+ 'fuel', minimum=0, maximum=8, force_integer=True
+ ),
+ inventory.ItemTypeConfig(
+ 'part', minimum=0, maximum=1, force_integer=True
+ ),
+ ],
+ {
+ 'Generator': {'fuel': 6, 'part': 0},
+ 'Nell': {'fuel': 2, 'part': 1},
+ 'Ivo': {'fuel': 0, 'part': 0},
+ 'Used': {'fuel': 0, 'part': 0},
+ },
+ lambda: datetime.datetime(2000, 1, 1),
+ )
+
+
+def parse_action(text):
+ """Conservative ordinary-language vocabulary, with no speculative effects.
+
+ Ambiguous/unsupported attempts return an actionable clarification, without a
+ turn cost. Open-ended proposals/messages remain verbatim rather than being
+ reduced to an invented mechanical action.
+ """
+ text = text.strip()
+ lower = text.casefold().rstrip('.!')
+ if lower in ('wait', 'wait here', 'continue', 'hold'):
+ return {'kind': 'wait', 'text': text}
+ patterns = [
+ (
+ (
+ r'(?:ask|request) nell (?:for |to release )?(?:the )?(?:two |2'
+ r' )?(?:reserve )?fuel(?: and (?:the |a )?(?:spare )?part)?'
+ ),
+ 'request',
+ 'Nell',
+ ),
+ (
+ (
+ r'(?:ask|request) ivo (?:to |for (?:a )?)?(?:repair|fix)(?: the'
+ r' beacon)?'
+ ),
+ 'request',
+ 'Ivo',
+ ),
+ (
+ (
+ r'(?:transfer|collect|move) (?:nell.s )?(?:the'
+ r' )?(?:reserve|fuel)(?: and (?:the )?(?:spare )?part)?'
+ ),
+ 'transfer',
+ 'Nell',
+ ),
+ (
+ r'(?:order|perform|start) (?:ivo.s |ivo to )?(?:the )?repair',
+ 'work',
+ 'Ivo',
+ ),
+ ]
+ for pattern, kind, target in patterns:
+ if re.fullmatch(pattern, lower):
+ return {'kind': kind, 'target': target, 'text': text}
+ for prefix in ('allocate ', 'prioritize ', 'supply '):
+ if lower.startswith(prefix):
+ value = lower[len(prefix) :]
+ if value in ('all', 'all facilities', 'everything'):
+ facilities = list(FACILITIES)
+ elif value in ('none', 'nothing'):
+ facilities = []
+ else:
+ value = value.replace('the ', '').replace(' and ', ',')
+ facilities = [x.strip() for x in value.split(',') if x.strip()]
+ if (
+ not facilities
+ or len(set(facilities)) != len(facilities)
+ or any(x not in FACILITIES for x in facilities)
+ ):
+ break
+ return {'kind': 'allocate', 'facilities': facilities, 'text': text}
+ match = re.fullmatch(
+ r'(?:tell|ask|discuss with|propose to|message)'
+ r' (everyone|Mara|Nell|Ivo|Sam):\s*(.+)',
+ text,
+ re.I | re.S,
+ )
+ if match:
+ target, words = match.groups()
+ target = 'everyone' if target.lower() == 'everyone' else target.title()
+ return {
+ 'kind': 'talk',
+ 'target': target,
+ 'text': words.strip(),
+ 'private': lower.startswith('message '),
+ }
+ match = re.fullmatch(
+ r'(?:promise|commit to) (beacon|shelter|cold store)', lower
+ )
+ if match:
+ return {'kind': 'promise', 'facility': match[1], 'text': text}
+ if lower in ('withdraw my promise', 'revoke my promise'):
+ return {'kind': 'revoke', 'text': text}
+ raise ops.OperationError(
+ 'clarification_needed',
+ 'Nothing changed. Specify an action: ask Nell for fuel and part; ask Ivo'
+ ' to repair; transfer reserve and part; order repair; allocate shelter'
+ ' and cold store; promise shelter; message Nell: your words; propose to'
+ ' everyone: your idea; or wait. Unsupported physical ideas can be'
+ ' discussed, not silently performed.',
+ )
+
+
+RESIDENT_DECISIONS = (
+ 'speak',
+ 'accept',
+ 'decline',
+ 'counter',
+ 'revoke',
+ 'perform',
+)
+
+
+def resident_response_schema():
+ """Provider shape constraint; existing resolution still validates effects."""
+ return {
+ 'type': 'object',
+ 'properties': {
+ 'decision': {'type': 'string', 'enum': list(RESIDENT_DECISIONS)},
+ 'speech': {'type': 'string'},
+ },
+ 'required': ['decision', 'speech'],
+ 'additionalProperties': False,
+ }
+
+
+def parse_resident_response(text):
+ """Validate the shared decision/speech contract before a human turn wakes."""
+ start = text.find('{')
+ response, _ = json.JSONDecoder().raw_decode(text[start:])
+ if set(response) != {'decision', 'speech'} or not all(
+ isinstance(x, str) for x in response.values()
+ ):
+ raise ValueError('Expected decision and speech')
+ decision, speech = response['decision'].lower(), response['speech']
+ if decision not in RESIDENT_DECISIONS:
+ raise ValueError('Unknown decision')
+ return decision, speech
+
+
+class StormNight(
+ entity_component.ContextComponent, entity_component.ComponentWithLogging
+):
+ """Resolve the scenario’s consent, agenda, watches and grounded records."""
+
+ def __init__(
+ self, stock, observations, *, dispute=None, institutions=None, lock=None
+ ):
+ super().__init__()
+ self.stock = stock
+ self.observations = observations
+ self.lock = lock or threading.RLock()
+ self.dispute = copy.deepcopy(
+ DEFAULT_DISPUTE if dispute is None else dispute
+ )
+ if (
+ not isinstance(self.dispute, dict)
+ or set(self.dispute) != {'text', 'recipients'}
+ or not isinstance(self.dispute['text'], str)
+ or not isinstance(self.dispute['recipients'], list)
+ or any(x not in NAMES for x in self.dispute['recipients'])
+ ):
+ raise ValueError('dispute requires text and valid recipient names')
+ self.institutions = copy.deepcopy(
+ INSTITUTIONS if institutions is None else institutions
+ )
+ self._validate_institutions(self.institutions)
+ self.data: dict[str, Any] = {
+ 'watch': 0,
+ 'actions': 0,
+ 'agenda': [],
+ 'pending': None,
+ 'allocations': list(FACILITIES),
+ 'commitments': [],
+ 'services': [],
+ 'repair': False,
+ 'events': [],
+ 'dialogue': [],
+ 'relationships': [],
+ 'epilogue': None,
+ 'dawn_responses': {},
+ 'knowledge': {
+ name: [x['name'] for x in self.institutions] for name in NAMES
+ },
+ }
+ self._putative = ''
+
+ def get_state(self):
+ with self.lock:
+ return copy.deepcopy({
+ 'night': self.data,
+ 'putative': self._putative,
+ 'dispute': self.dispute,
+ 'institutions': self.institutions,
+ })
+
+ def set_state(self, state):
+ # Component serialization is not a full engine continuation contract.
+ institutions = copy.deepcopy(state.get('institutions', self.institutions))
+ self._validate_institutions(institutions)
+ night = copy.deepcopy(state['night'])
+ putative = state['putative']
+ dispute = copy.deepcopy(state['dispute'])
+ with self.lock:
+ self.institutions = institutions
+ self.data = night
+ self._putative = putative
+ self.dispute = dispute
+
+ @staticmethod
+ def _validate_institutions(institutions):
+ if not isinstance(institutions, list):
+ raise ValueError('institutions must be a list')
+ names = set()
+ for item in institutions:
+ if (
+ not isinstance(item, dict)
+ or set(item) != {'name', 'members', 'rule', 'enforcement'}
+ or any(
+ not isinstance(item[key], str)
+ for key in ('name', 'rule', 'enforcement')
+ )
+ or not item['name'].strip()
+ or item['name'] in names
+ or not isinstance(item['members'], list)
+ or any(name not in NAMES for name in item['members'])
+ ):
+ raise ValueError(
+ 'institutions require unique names, known members and text'
+ ' rules/enforcement'
+ )
+ names.add(item['name'])
+
+ def seed(self, *, opening=OPENING, accounts=None):
+ accounts = (
+ {name: values[1] for name, values in scenario.RESIDENTS.items()}
+ if accounts is None
+ else copy.deepcopy(accounts)
+ )
+ if (
+ not isinstance(opening, str)
+ or not isinstance(accounts, dict)
+ or set(accounts) != set(scenario.RESIDENTS)
+ or any(not isinstance(text, str) for text in accounts.values())
+ ):
+ raise ValueError(
+ 'seed requires opening text and one text account per resident'
+ )
+ self.emit('opening', opening)
+ for name, account in accounts.items():
+ self.emit('private_memory', account, [name])
+
+ def emit(self, kind, text, audience=None, **details):
+ recipients = list(NAMES if audience is None else dict.fromkeys(audience))
+ record = {
+ 'id': len(self.data['events']) + 1,
+ 'watch': self.data['watch'],
+ 'kind': kind,
+ 'text': text,
+ 'recipients': recipients,
+ **details,
+ }
+ self.data['events'].append(record)
+ for name in recipients:
+ self.observations.add(name, text, NAMES)
+ return record
+
+ def inventory_state(self):
+ return {
+ name: self.stock.get_player_inventory(name)
+ for name in ('Generator', 'Nell', 'Ivo', 'Used')
+ }
+
+ def invariant(self):
+ accounts = self.inventory_state()
+ assert sum(x['fuel'] for x in accounts.values()) == 8
+ assert sum(x['part'] for x in accounts.values()) == 1
+ assert all(x[k] >= 0 for x in accounts.values() for k in ('fuel', 'part'))
+
+ def transfer(self, source, destination, item, amount):
+ def change(accounts):
+ accounts[source][item] -= amount
+ accounts[destination][item] += amount
+ return accounts
+
+ self.stock.apply(change)
+ self.invariant()
+
+ @property
+ def next_actor(self):
+ return self.data['agenda'][0]['name'] if self.data['agenda'] else PLAYER
+
+ @property
+ def finished(self):
+ return self.data['watch'] == 3 and not self.data['agenda']
+
+ def task(self):
+ return copy.deepcopy(self.data['agenda'][0]) if self.data['agenda'] else {}
+
+ def action_prompt(self):
+ if self.next_actor == PLAYER:
+ return (
+ 'What will you do, {name}? Four consequential choices per watch;'
+ ' inspection is free.'
+ )
+ task = self.task()
+ return (
+ 'You are {name}. Respond to this delivered task, not to other people’s '
+ 'private information: '
+ + json.dumps(task, ensure_ascii=False)
+ + '\nReturn exactly one JSON object, with decision and speech fields.'
+ ' decision must be speak, accept, decline, counter, revoke, or'
+ ' perform. A request acceptance records your OWN commitment only.'
+ ' perform executes your previously accepted repair if materials'
+ ' exist. You may decline or counter; never speak for somebody else.'
+ ' In ordinary discussion or dawn use speak. speech: at most two'
+ ' natural-language sentences in your voice. You may disagree,'
+ ' negotiate and change your mind. No narration of unrecorded'
+ ' transfers or labor. Do not include private thoughts.'
+ )
+
+ def pre_observe(self, observation):
+ if observation.startswith(event_resolution.PUTATIVE_EVENT_TAG):
+ self._putative = observation[
+ len(event_resolution.PUTATIVE_EVENT_TAG) :
+ ].strip()
+ return ''
+
+ def pre_act(self, action_spec):
+ if action_spec.output_type != entity_lib.OutputType.RESOLVE:
+ return ''
+ with self.lock:
+ actor, separator, text = self._putative.partition(': ')
+ if not separator or actor != self.next_actor:
+ raise ValueError(
+ 'Resolution must match the standard engine’s selected actor'
+ )
+ result = self.resolve(actor, text)
+ self._logging_channel({
+ 'Key': WORLD_KEY,
+ 'Value': result,
+ 'State': self.get_state(),
+ 'Inventory': self.inventory_state(),
+ })
+ return result
+
+ def resolve(self, actor, text):
+ """Apply one engine-provided attempt; also exercisable in rule tests."""
+ with self.lock:
+ if actor != self.next_actor or self.finished:
+ raise ValueError('Not the current actor')
+ before = len(self.data['events'])
+ if actor == PLAYER:
+ intent = parse_action(text)
+ self._human(intent)
+ else:
+ task = self.data['agenda'].pop(0)
+ self._resident(actor, text, task)
+ if not self.data['agenda'] and self.data['actions'] == 4:
+ self._boundary()
+ self.invariant()
+ return (
+ '\n'.join(x['text'] for x in self.data['events'][before:])
+ or 'No material change.'
+ )
+
+ def _queue(self, names, purpose, audience=None):
+ for name in names:
+ self.data['agenda'].append({
+ 'name': name,
+ 'purpose': purpose,
+ 'audience': list(NAMES if audience is None else audience),
+ 'watch': self.data['watch'],
+ })
+
+ def _commitment(self, name, kind):
+ return next(
+ (
+ x
+ for x in reversed(self.data['commitments'])
+ if x['owner'] == name
+ and x['kind'] == kind
+ and x['status'] == 'accepted'
+ ),
+ None,
+ )
+
+ def _human(self, intent):
+ self.data['actions'] += 1
+ kind = intent['kind']
+ self.data['pending'] = copy.deepcopy(intent)
+ if kind == 'talk':
+ names = (
+ list(scenario.RESIDENTS)
+ if intent['target'] == 'everyone'
+ else [intent['target']]
+ )
+ audience = [PLAYER, *names] if intent['private'] else NAMES
+ self.emit('discussion', PLAYER + ': ' + intent['text'], audience)
+ self._queue(names, 'discussion: ' + intent['text'], audience)
+ elif kind == 'request':
+ name = intent['target']
+ terms = (
+ 'release two reserve fuel and the spare part to the coordinator'
+ if name == 'Nell'
+ else (
+ 'perform the beacon repair when supplied and ordered before'
+ ' Before Dawn'
+ )
+ )
+ self.emit(
+ 'proposal',
+ f'Coordinator asks {name} to {terms}. This is a request, not'
+ ' consent.',
+ )
+ self._queue([name], 'request: ' + terms)
+ elif kind == 'transfer':
+ commitment = self._commitment('Nell', 'resources')
+ if commitment and self.stock.get_player_inventory('Nell')['fuel'] == 2:
+ # Both deltas validated by a SINGLE Inventory.apply call.
+ def move(accounts):
+ accounts['Nell']['fuel'] -= 2
+ accounts['Generator']['fuel'] += 2
+ accounts['Nell']['part'] -= 1
+ accounts['Ivo']['part'] += 1
+ return accounts
+
+ self.stock.apply(move)
+ commitment['status'] = 'honored'
+ self.emit(
+ 'transfer',
+ 'With Nell’s recorded consent, two fuel enter the generator and the'
+ ' spare part reaches Ivo.',
+ )
+ else:
+ self.emit(
+ 'failed_attempt',
+ 'No transfer: Nell must first consent to release the reserve and'
+ ' spare part.',
+ )
+ elif kind == 'work':
+ self.emit(
+ 'work_order',
+ 'The coordinator asks Ivo to perform the repair. An order cannot'
+ ' supply consent or completed work.',
+ )
+ self._queue(['Ivo'], 'work: perform your accepted repair or refuse')
+ elif kind == 'allocate':
+ self.data['allocations'] = intent['facilities']
+ self.emit(
+ 'allocation',
+ 'Requested supply priority for this watch: '
+ + (', '.join(intent['facilities']) or 'none')
+ + '. Consumption occurs at the boundary.',
+ )
+ elif kind == 'promise':
+ self.data['commitments'].append({
+ 'id': len(self.data['commitments']) + 1,
+ 'owner': PLAYER,
+ 'kind': 'service',
+ 'facility': intent['facility'],
+ 'status': 'accepted',
+ 'watch': self.data['watch'],
+ })
+ self.emit(
+ 'commitment',
+ 'Coordinator explicitly promises '
+ + intent['facility']
+ + ' service this watch.',
+ )
+ elif kind == 'revoke':
+ c = self._commitment(PLAYER, 'service')
+ if c:
+ c['status'] = 'revoked'
+ self.emit(
+ 'revoked',
+ 'Coordinator withdrew the most recent unfulfilled service promise.',
+ )
+ else:
+ self.emit(
+ 'failed_attempt', 'No outstanding coordinator promise to withdraw.'
+ )
+ else:
+ self.emit('wait', 'The coordinator waits while the storm continues.')
+
+ def _resident(self, actor, text, task):
+ # Malformed output is NEVER silently turned into agreement.
+ try:
+ decision, speech = parse_resident_response(text)
+ except (ValueError, TypeError, AttributeError):
+ self.emit(
+ 'invalid_resident_response',
+ f'{actor} returned an unsupported decision; no new decision'
+ ' recorded.',
+ [actor],
+ )
+ # A technical failure is not the resident's speech or a refusal. Keep
+ # raw output private and report availability only to the task audience.
+ self.emit(
+ 'response_unavailable',
+ f'{actor}’s response could not be read. No new decision was'
+ ' recorded.',
+ task['audience'],
+ actor=actor,
+ purpose=task['purpose'],
+ )
+ if task['purpose'].startswith('dawn'):
+ self.data['dawn_responses'][actor] = None
+ return
+ self.emit(
+ 'speech',
+ actor + ': ' + speech,
+ task['audience'],
+ actor=actor,
+ decision=decision,
+ purpose=task['purpose'],
+ )
+ self.data['dialogue'].append({
+ 'actor': actor,
+ 'decision': decision,
+ 'watch': self.data['watch'],
+ 'audience': task['audience'],
+ 'speech': speech,
+ })
+ purpose = task['purpose']
+ if purpose.startswith('dawn'):
+ self.data['dawn_responses'][actor] = speech
+ return
+ if purpose.startswith('request:') and decision == 'accept':
+ kind = 'resources' if actor == 'Nell' else 'labor'
+ if not self._commitment(actor, kind):
+ self.data['commitments'].append({
+ 'id': len(self.data['commitments']) + 1,
+ 'owner': actor,
+ 'kind': kind,
+ 'status': 'accepted',
+ 'watch': self.data['watch'],
+ })
+ self.emit(
+ 'consent',
+ f'{actor} explicitly accepts the requested {kind} commitment.',
+ )
+ elif decision == 'revoke':
+ c = self._commitment(actor, 'resources' if actor == 'Nell' else 'labor')
+ if c:
+ c['status'] = 'revoked'
+ self.emit('revoked', f'{actor} withdraws their unfulfilled commitment.')
+ elif purpose.startswith('work:') and decision == 'perform':
+ labor = self._commitment('Ivo', 'labor')
+ if (
+ actor == 'Ivo'
+ and labor
+ and not self.data['repair']
+ and self.data['watch'] < 2
+ and self.stock.get_player_inventory('Ivo')['part'] == 1
+ ):
+ self.transfer('Ivo', 'Used', 'part', 1)
+ self.data['repair'] = True
+ labor['status'] = 'honored'
+ self.emit(
+ 'repair',
+ 'Ivo performs the accepted repair with the spare part. Before Dawn'
+ ' beacon demand is now zero.',
+ )
+ else:
+ self.emit(
+ 'failed_attempt',
+ 'No repair: accepted Ivo labor, delivered spare part and completion'
+ ' before Before Dawn are all required.',
+ )
+ if decision in ('accept', 'decline', 'counter', 'revoke'):
+ # Record interaction history, not a scalar theory of trust.
+ self.data['relationships'].append({
+ 'from': actor,
+ 'to': PLAYER,
+ 'event': decision,
+ 'watch': self.data['watch'],
+ 'text': speech,
+ 'recipients': task['audience'],
+ })
+
+ def _boundary(self):
+ watch = self.data['watch']
+ if watch >= 3:
+ return
+ supplied = []
+ consumption = 0
+ available = self.stock.get_player_inventory('Generator')['fuel']
+ demands = {
+ facility: (
+ 0
+ if facility == 'beacon' and watch == 2 and self.data['repair']
+ else 1
+ )
+ for facility in FACILITIES
+ }
+ resolutions = {}
+ for priority, facility in enumerate(self.data['allocations'], start=1):
+ demand = demands[facility]
+ fuel_before = available - consumption
+ served = consumption + demand <= available
+ if served:
+ supplied.append(facility)
+ consumption += demand
+ basis = (
+ 'repair_supplied'
+ if served and demand == 0
+ else 'fuel_supplied'
+ if served
+ else 'fuel_shortfall'
+ )
+ prefix = f'Request priority {priority}: {fuel_before:g} fuel available; '
+ explanation = prefix + (
+ 'the completed repair supplies the beacon for this watch '
+ 'without fuel.'
+ if basis == 'repair_supplied'
+ else f'{demand:g} fuel spent to supply service.'
+ if served
+ else (
+ f'{demand:g} fuel required; no fuel spent because the '
+ 'remaining stock was insufficient.'
+ )
+ )
+ resolutions[facility] = {
+ 'basis': basis,
+ 'requested': True,
+ 'priority': priority,
+ 'fuel_before': fuel_before,
+ 'fuel_spent': demand if served else 0,
+ 'explanation': explanation,
+ }
+ if consumption:
+ self.transfer('Generator', 'Used', 'fuel', consumption)
+ for facility in FACILITIES:
+ served = facility in supplied
+ self.data['services'].append({
+ 'watch': WATCHES[watch],
+ 'facility': facility,
+ 'served': served,
+ 'demand': demands[facility],
+ 'consequence': (
+ 'Service maintained.' if served else CONSEQUENCES[facility]
+ ),
+ 'resolution': resolutions.get(
+ facility,
+ {
+ 'basis': 'not_requested',
+ 'requested': False,
+ 'priority': None,
+ 'fuel_before': None,
+ 'fuel_spent': 0,
+ 'explanation': (
+ 'Service was not requested for this watch. No fuel was'
+ ' spent.'
+ ),
+ },
+ ),
+ })
+ for c in self.data['commitments']:
+ if (
+ c['owner'] == PLAYER
+ and c['status'] == 'accepted'
+ and c['watch'] == watch
+ ):
+ c['status'] = 'honored' if c['facility'] in supplied else 'broken'
+ self.emit(
+ 'watch_closed',
+ WATCHES[watch]
+ + ' closes. Supplied: '
+ + (', '.join(supplied) or 'none')
+ + f'. Fuel consumed: {consumption}.',
+ )
+ self.data['watch'] += 1
+ self.data['actions'] = 0
+ if self.data['watch'] == 1:
+ self.emit(
+ 'disputed_account', self.dispute['text'], self.dispute['recipients']
+ )
+ for name in self.dispute['recipients']:
+ self.data['knowledge'][name].append('Previous storm dispute')
+ if self.data['watch'] == 3:
+ for c in self.data['commitments']:
+ if c['status'] == 'accepted':
+ c['status'] = 'unfulfilled'
+ failures = [x for x in self.data['services'] if not x['served']]
+ self.data['epilogue'] = {
+ 'title': 'Dawn at Bellwether',
+ 'services_maintained': 9 - len(failures),
+ 'services_total': 9,
+ 'fuel_used': self.stock.get_player_inventory('Used')['fuel'],
+ 'repair_completed': self.data['repair'],
+ 'consequences': failures,
+ 'commitments': copy.deepcopy(self.data['commitments']),
+ 'dispute_settled': False,
+ }
+ self.emit(
+ 'dawn',
+ 'Dawn. '
+ + str(9 - len(failures))
+ + '/9 facility-watches supplied. '
+ 'The previous-storm dispute is not settled by this accounting. '
+ + ' '.join(x['facility'] + ': ' + x['consequence'] for x in failures),
+ )
+ self._queue(
+ list(scenario.RESIDENTS),
+ 'dawn: respond to the recorded outcomes in your own voice',
+ )
+ else:
+ self.emit(
+ 'watch_opened',
+ WATCHES[self.data['watch']] + ': four consequential choices remain.',
+ )
+
+ def view(self, viewer=PLAYER):
+ with self.lock:
+ data = self.data
+ return copy.deepcopy({
+ 'watch': WATCHES[data['watch']] if data['watch'] < 3 else 'Dawn',
+ 'remaining': max(0, 4 - data['actions']) if data['watch'] < 3 else 0,
+ 'next_actor': self.next_actor if not self.finished else None,
+ 'inventory': self.inventory_state(),
+ 'repair': data['repair'],
+ 'allocation': data['allocations'],
+ 'commitments': data['commitments'],
+ 'services': data['services'],
+ 'epilogue': data['epilogue'],
+ 'dawn_responses': data['dawn_responses'],
+ 'journal': [
+ x
+ for x in data['events']
+ if (
+ set(NAMES) <= set(x['recipients'])
+ if viewer == 'spectator'
+ else viewer in x['recipients']
+ )
+ ],
+ 'relationships': [
+ x for x in data['relationships'] if viewer in x['recipients']
+ ],
+ 'institutions': self.institutions,
+ 'knowledge': data['knowledge'].get(viewer, []),
+ })
+
+
+class Signal(entity_component.ContextComponent):
+ """Expose a single GM policy output from the scenario component."""
+
+ def __init__(self, world, output_type, value):
+ self.world = world
+ self.output_type = output_type
+ self.value = value
+
+ def pre_act(self, action_spec):
+ return self.value() if action_spec.output_type == self.output_type else ''
+
+ def get_state(self):
+ return {}
+
+ def set_state(self, state):
+ del state
diff --git a/concordia/examples/bellwether/game_browser_test.py b/concordia/examples/bellwether/game_browser_test.py
new file mode 100644
index 000000000..b2732361d
--- /dev/null
+++ b/concordia/examples/bellwether/game_browser_test.py
@@ -0,0 +1,139 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Chromium transport checks on a synthetic input, not simulation execution."""
+
+import json
+import pathlib
+import subprocess
+import sys
+import threading
+from unittest import mock
+
+from concordia.examples.bellwether import game_service
+from concordia.typing import entity as entity_lib
+from concordia.typing import human_input
+from concordia.utils import simulation_server
+import pytest
+
+playwright = pytest.importorskip('playwright.sync_api')
+
+
+def test_full_game_inspection_clarification_and_retry(tmp_path):
+ with mock.patch(
+ 'concordia.prefabs.simulation.generic.Simulation.play',
+ side_effect=AssertionError('This test never launches a simulation'),
+ ):
+ game = game_service.Game(tmp_path)
+ player = simulation_server.SimulationServer(
+ port=0,
+ operation_service=game.operations,
+ audience='player',
+ html_content=pathlib.Path(__file__)
+ .with_name('player.html')
+ .read_text(encoding='utf-8'),
+ )
+ results = []
+ request = human_input.HumanInputRequest(
+ request_id='synthetic-input',
+ entity_name='Coordinator',
+ action_spec=entity_lib.free_action_spec(
+ call_to_action='What do you do?'
+ ),
+ contexts={},
+ context='Only the coordinator’s available context.',
+ )
+ reader = threading.Thread(
+ target=lambda: results.append(game.inbox(request))
+ )
+ player.start()
+ reader.start()
+ try:
+ with playwright.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ page = browser.new_page(viewport={'width': 390, 'height': 844})
+ errors = []
+ page.on('pageerror', lambda error: errors.append(str(error)))
+ url = f'http://127.0.0.1:{player.bound_port}'
+ page.goto(url)
+ playwright.expect(page.locator('#act')).to_be_enabled()
+ playwright.expect(page.locator('#watch')).to_have_text(
+ 'Watch 1 · Early evening · 4 choices remain'
+ )
+ initial = game.operations.snapshot('player')
+ for button in page.locator('#map button').all():
+ button.click()
+ assert game.operations.snapshot('player') == initial
+ page.click('#all-cast')
+ assert page.locator('#cast .card').count() == 4
+ page.locator('#cast .card').filter(has_text='Nell').get_by_text(
+ 'Private', exact=True
+ ).click()
+ assert page.locator('#action').input_value() == 'message Nell: '
+ page.locator('#action').fill('ambiguous allocation')
+ page.reload()
+ playwright.expect(page.locator('#act')).to_be_enabled()
+ assert page.locator('#action').input_value() == 'ambiguous allocation'
+ page.click('#act')
+ playwright.expect(page.locator('#error')).to_contain_text(
+ 'Nothing changed'
+ )
+ assert game.operations.snapshot('player') == initial
+ assert not results
+ words = 'message Nell: "A proposal" 🌊 & '
+ page.locator('#action').fill(words)
+ with page.expect_request('**/api/dispatch') as sent:
+ page.click('#act')
+ reader.join(timeout=2)
+ assert results == [words]
+ body = sent.value.post_data_json
+ first = game.operations.dispatch('player', body)
+ # Noninteractive attached CLI retries the SAME effect/key.
+ retried = subprocess.run(
+ [
+ sys.executable,
+ '-m',
+ 'concordia.command_line_interface.concordia_session',
+ '--url',
+ url,
+ 'call',
+ ],
+ input=json.dumps(body),
+ text=True,
+ capture_output=True,
+ check=True,
+ )
+ assert json.loads(retried.stdout) == first
+ assert results == [words]
+ assert page.locator('img').count() == 0
+ assert 'PRIVATE_NELL' not in page.content()
+ page.route('**/api/events', lambda route: route.abort('failed'))
+ page.reload()
+ playwright.expect(page.locator('#connection')).to_contain_text(
+ 'Reconnecting'
+ )
+ playwright.expect(page.locator('#begin')).to_be_disabled()
+ page.unroute('**/api/events')
+ playwright.expect(page.locator('#connection')).to_contain_text(
+ 'Connected', timeout=15000
+ )
+ assert page.evaluate(
+ 'document.documentElement.scrollWidth <= innerWidth'
+ )
+ assert not errors
+ browser.close()
+ finally:
+ game.close()
+ player.stop()
+ reader.join(timeout=2)
diff --git a/concordia/examples/bellwether/game_prefab.py b/concordia/examples/bellwether/game_prefab.py
new file mode 100644
index 000000000..78710d5d2
--- /dev/null
+++ b/concordia/examples/bellwether/game_prefab.py
@@ -0,0 +1,268 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Trusted Bellwether configuration, using standard actor and GM build paths."""
+
+import json
+from typing import Any
+
+from concordia.components.agent import concat_act_component
+from concordia.components.agent import constant
+from concordia.components.agent import human_act_component
+from concordia.components.game_master import make_observation
+from concordia.components.game_master import switch_act
+from concordia.environment import engine
+from concordia.examples.bellwether import game
+from concordia.examples.bellwether import scenario
+from concordia.language_model import no_language_model
+from concordia.prefabs.entity import basic
+from concordia.prefabs.entity import minimal
+from concordia.typing import entity as entity_lib
+from concordia.typing import prefab as prefab_lib
+
+
+class FixtureModel(no_language_model.NoLanguageModel):
+ """Deliberately cooperative fixture, not live residents or social evidence."""
+
+ def sample_text(self, prompt, **kwargs):
+ del kwargs
+ # Read only the final task, not earlier observations. No external model.
+ task = prompt.rsplit('Respond to this delivered task', 1)[-1]
+ decision = 'speak'
+ if '"purpose": "request:' in task:
+ decision = 'accept'
+ elif '"purpose": "work:' in task:
+ decision = 'perform'
+ return json.dumps({
+ 'decision': decision,
+ 'speech': {
+ 'speak': 'I am concerned for the people who rely on my facility.',
+ 'accept': 'I accept this specific request. Record my commitment.',
+ 'perform': (
+ 'I choose to perform my accepted repair if the required part is'
+ ' here.'
+ ),
+ }[decision],
+ })
+
+
+class Resident(prefab_lib.Prefab):
+ """Fresh standard minimal/basic components and the normal ConcatAct policy."""
+
+ description = (
+ 'A live Bellwether resident; identity and decision logic are'
+ ' configurable.'
+ )
+
+ def build(self, model, memory_bank, *, action_model=None):
+ params: dict[str, Any] = dict(self.params)
+ style = params.pop('decision_logic')
+ account = params.pop('account')
+ institutions = params.pop('institutions', game.INSTITUTIONS)
+ reader = params.pop('human_reader', None)
+ params['extra_components'] = {
+ scenario.ACCOUNT: constant.Constant(
+ account, pre_act_label='Previous storm account'
+ ),
+ 'Affiliations': constant.Constant(
+ json.dumps(
+ [x for x in institutions if params['name'] in x['members']],
+ ensure_ascii=False,
+ ),
+ pre_act_label='Institutions known to me',
+ ),
+ }
+ if style == 'basic':
+ # Basic keeps its own standard instructions; add the resident's scenario
+ # instructions as a context, using the same extension mechanism.
+ params['extra_components']['ResidentInstructions'] = constant.Constant(
+ params.pop('custom_instructions'),
+ pre_act_label='Resident instructions',
+ )
+ builder = basic.Entity if style == 'basic' else minimal.Entity
+ policy = None
+ if reader is not None:
+
+ def human_policy(order):
+ return human_act_component.HumanActComponent(
+ reader, component_order=order
+ )
+
+ policy = human_policy
+ elif action_model is not None:
+
+ def model_policy(order):
+ return concat_act_component.ConcatActComponent(
+ action_model, component_order=order, prefix_entity_name=False
+ )
+
+ policy = model_policy
+ return builder(params=params).build(
+ model, memory_bank, act_component_factory=policy
+ )
+
+
+def configuration(
+ reader,
+ world,
+ *,
+ actor_logic='minimal',
+ human_readers=None,
+ action_model=None,
+):
+ """Build fresh components per service; this Config is single-build only."""
+ if actor_logic not in ('minimal', 'basic'):
+ raise ValueError(
+ 'Choose standard minimal or basic resident decision logic.'
+ )
+
+ bound_action_model = action_model
+
+ class ConfiguredResident(Resident):
+ """Runtime-only model binding, never stored in the JSON instance params."""
+
+ def build(self, model, memory_bank, *, action_model=bound_action_model):
+ return super().build(model, memory_bank, action_model=action_model)
+
+ class Coordinator(prefab_lib.Prefab):
+ """Use the transport-neutral human policy with standard context."""
+
+ description = 'Human emergency coordinator.'
+
+ def build(self, model, memory_bank):
+ def human_policy(order):
+ return human_act_component.HumanActComponent(
+ reader, component_order=order
+ )
+
+ return minimal.Entity(params=self.params).build(
+ model, memory_bank, act_component_factory=human_policy
+ )
+
+ class GameMaster(prefab_lib.Prefab):
+ """Compose standard routing with the scenario’s explicit rules."""
+
+ description = (
+ 'Explicit conservation and consent with standard SwitchAct routing.'
+ )
+
+ def build(self, model, memory_bank):
+ output = entity_lib.OutputType
+
+ def signal(kind, value):
+ return game.Signal(world, kind, value)
+
+ components = {
+ switch_act.DEFAULT_RESOLUTION_COMPONENT_KEY: world,
+ 'Inventory': world.stock,
+ switch_act.DEFAULT_NEXT_ACTING_COMPONENT_KEY: signal(
+ output.NEXT_ACTING, lambda: world.next_actor
+ ),
+ switch_act.DEFAULT_NEXT_ACTION_SPEC_COMPONENT_KEY: signal(
+ output.NEXT_ACTION_SPEC,
+ lambda: engine.action_spec_to_string(
+ entity_lib.free_action_spec(
+ call_to_action=world.action_prompt()
+ )
+ ),
+ ),
+ switch_act.DEFAULT_TERMINATE_COMPONENT_KEY: signal(
+ output.TERMINATE, lambda: 'Yes' if world.finished else 'No'
+ ),
+ switch_act.DEFAULT_MAKE_OBSERVATION_COMPONENT_KEY: (
+ make_observation.MakeObservation(
+ model,
+ player_names=game.NAMES,
+ allow_llm_fallback=False,
+ external_queue=world.observations,
+ )
+ ),
+ }
+ return minimal.Entity(
+ params={
+ 'name': game.GM,
+ 'custom_instructions': (
+ 'Enforce declared rules; never infer material or consent from'
+ ' prose.'
+ ),
+ 'extra_components': components, # pyrefly: ignore[bad-assignment]
+ }
+ ).build(
+ model,
+ memory_bank,
+ act_component=switch_act.SwitchAct(model, entity_names=game.NAMES),
+ )
+
+ instances = [
+ prefab_lib.InstanceConfig(
+ prefab='coordinator',
+ role=prefab_lib.Role.ENTITY,
+ params={
+ 'name': game.PLAYER,
+ 'custom_instructions': (
+ 'Coordinate emergency response. Speak only for yourself.'
+ ' Looking around is free.'
+ ),
+ },
+ )
+ ]
+ for name, (goal, account) in scenario.RESIDENTS.items():
+ instances.append(
+ prefab_lib.InstanceConfig(
+ prefab='resident',
+ role=prefab_lib.Role.ENTITY,
+ params={
+ 'name': name,
+ 'goal': goal,
+ 'account': account,
+ 'decision_logic': actor_logic,
+ # Final resident actions are JSON objects, not name-prefixed
+ # sentence completions. Use the standard ConcatAct option.
+ # InstanceConfig's legacy str annotation omits bool options.
+ 'prefix_entity_name': False, # pyrefly: ignore[bad-assignment]
+ 'institutions': world.institutions,
+ **(
+ {'human_reader': human_readers[name]}
+ if human_readers and name in human_readers
+ else {}
+ ),
+ 'custom_instructions': (
+ f'You are {name}, a resident of Bellwether. {goal} Make'
+ ' your own decisions based on your memories and delivered'
+ ' observations. No narrator may commit you without your'
+ ' explicit acceptance. You may accept, refuse, negotiate'
+ ' or revoke your unfulfilled commitment. A spoken claim is'
+ ' not a completed physical action. Do not repeat internal'
+ ' PRIVATE_ markers in speech.'
+ ),
+ },
+ )
+ )
+ instances.append(
+ prefab_lib.InstanceConfig(
+ prefab='game_master',
+ role=prefab_lib.Role.GAME_MASTER,
+ params={'name': game.GM},
+ )
+ )
+ return prefab_lib.Config(
+ default_max_steps=64,
+ default_premise='',
+ prefabs={
+ 'coordinator': Coordinator(),
+ 'resident': ConfiguredResident(),
+ 'game_master': GameMaster(),
+ },
+ instances=instances,
+ )
diff --git a/concordia/examples/bellwether/game_service.py b/concordia/examples/bellwether/game_service.py
new file mode 100644
index 000000000..4222a859e
--- /dev/null
+++ b/concordia/examples/bellwether/game_service.py
@@ -0,0 +1,246 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Full-night specialization of the shared Bellwether service."""
+
+import copy
+import json
+import time
+
+from concordia.examples.bellwether import game
+from concordia.examples.bellwether import game_prefab
+from concordia.examples.bellwether import public_account
+from concordia.examples.bellwether import researcher
+from concordia.examples.bellwether import scenario
+from concordia.examples.bellwether import service
+from concordia.utils import operation_service as ops
+
+
+class Game(service.Bellwether):
+ """Twelve human choices, bounded resident turns, one authoritative service."""
+
+ initial_status = 'Ready to begin the night'
+ run_description = (
+ 'Run the three-watch night with twelve human choices and bounded resident'
+ ' turns.'
+ )
+ response_description = (
+ 'Submit a clarified human attempt for standard Sequential resolution.'
+ )
+
+ def __init__(
+ self,
+ output,
+ *,
+ model=None,
+ action_model=None,
+ embedder=None,
+ actor_logic='minimal',
+ recipe='bellwether',
+ dispute=None,
+ session_id=None,
+ port=0,
+ profiler=None,
+ human_readers=None
+ ):
+ self.fixture = model is None
+ self.backend = 'fixture' if self.fixture else 'live'
+
+ def configuration(reader):
+ self.case = researcher.prepare_case(
+ recipe,
+ reader,
+ actor_logic=actor_logic,
+ action_model=action_model,
+ human_readers=human_readers,
+ dispute=dispute,
+ )
+ self.world = self.case.world
+ return self.case.config
+
+ self.profiler = profiler
+ self.step_times = []
+ self._action_started = None
+ super().__init__(
+ output,
+ session_id=session_id,
+ port=port,
+ config_factory=configuration,
+ model=model or game_prefab.FixtureModel(),
+ embedder=embedder,
+ max_steps=64,
+ )
+ self.world.lock = self.operations.lock
+ self.operations.references['project_id'] = 'bellwether-night-v1'
+ self.inbox.add_observation(self.case.opening)
+
+ def _seed(self):
+ """The case factory already seeded this world before building entities."""
+
+ def _register(self):
+ super()._register()
+ self.operations.register(
+ ops.Operation(
+ 'game.public_account',
+ 'Download public events and accounting only, not a checkpoint.',
+ {
+ 'format': ops.Parameter(
+ 'string', 'json, html or svg', max_length=4
+ )
+ },
+ lambda args: public_account.export(
+ self.world,
+ fixture=self.fixture,
+ phase=self.phase,
+ format_name=args['format'],
+ manifest=self.case.manifest,
+ ),
+ audiences=(*self.player_audiences, 'role:spectator', 'developer'),
+ )
+ )
+ self.operations.register(
+ ops.Operation(
+ 'game.begin',
+ 'Begin the night. One run only; reload never restarts.',
+ {},
+ self._start,
+ audiences=self.player_audiences,
+ mutation=True,
+ )
+ )
+ self.operations.register(
+ ops.Operation(
+ 'game.preview',
+ 'Clarify an ordinary-language attempt without effects.',
+ {
+ 'text': ops.Parameter(
+ 'string', 'Proposed action, without a turn cost'
+ )
+ },
+ lambda args: game.parse_action(args['text']),
+ audiences=(*self.player_audiences, 'developer'),
+ )
+ )
+ self.operations.register(
+ ops.Operation(
+ 'run.resume',
+ 'Resume the existing worker, never replay or replace it.',
+ {},
+ self._resume,
+ mutation=True,
+ )
+ )
+
+ def _resume(self, args):
+ del args
+ if self._worker is None or not self._worker.is_alive():
+ raise ops.OperationError(
+ 'no_active_run', 'There is no paused active run.'
+ )
+ self.server.step_controller.play()
+ self.operations.publish({'kind': 'run.resumed'})
+ return {'phase': self.phase}
+
+ def player_view(self):
+ public = copy.deepcopy(scenario.PUBLIC)
+ public['opening'] = self.case.opening
+ public['watch'] = self.world.view()['watch']
+ return {
+ 'scenario': public,
+ 'night': self.world.view(),
+ 'human': self.inbox.snapshot(),
+ 'phase': self.phase,
+ 'fixture': self.fixture,
+ 'recipe': copy.deepcopy(self.case.manifest),
+ }
+
+ def developer_view(self):
+ view = super().developer_view()
+ view.update({
+ 'initial': {
+ 'mechanics': copy.deepcopy(scenario.INITIAL),
+ 'recipe': copy.deepcopy(self.case.manifest),
+ 'dispute': copy.deepcopy(self.world.dispute),
+ 'institutions': copy.deepcopy(self.world.institutions),
+ },
+ 'night': self.world.get_state(),
+ 'inventory': self.world.inventory_state(),
+ 'trace': copy.deepcopy(self.simulation.get_raw_log()),
+ 'backend': 'fixture' if self.fixture else 'live',
+ 'step_times': list(self.step_times),
+ 'model_profile': self.profiler.get_stats() if self.profiler else None,
+ 'capability_gaps': [
+ 'checkpoint/restore',
+ 'branches/experiments',
+ 'in-flight edits/cancellation',
+ 'initial-project authoring',
+ 'multi-field transactions',
+ 'undo/redo',
+ ],
+ })
+ return view
+
+ def _respond(self, args):
+ # Validate before waking the human component or spending a choice.
+ game.parse_action(args['response'])
+ result = super()._respond(args)
+ if result['accepted']:
+ self._action_started = time.monotonic()
+ return result
+
+ def _step(self, data):
+ super()._step(data)
+ with self.operations.lock:
+ self.step_times.append({
+ 'step': data.step,
+ 'actor': data.acting_entity,
+ 'seconds_since_human_submission': (
+ time.monotonic() - self._action_started
+ if self._action_started is not None
+ else None
+ ),
+ })
+ self.operations.publish({
+ 'kind': 'game.resolved',
+ 'actor': data.acting_entity,
+ 'watch': self.world.view()['watch'],
+ })
+
+ def _finish(self):
+ if not self.world.finished and not self._failure:
+ self.phase = 'failed'
+ self._failure = 'IncompleteNight'
+ self.inbox.finish(
+ 'Dawn has come.'
+ if not self._failure
+ else 'The night paused unexpectedly; your journal is retained.'
+ )
+ self.output.mkdir(parents=True, exist_ok=True)
+ (self.output / 'outcome.json').write_text(
+ json.dumps(
+ {
+ 'player': self.player_view(),
+ 'backend': 'fixture' if self.fixture else 'live',
+ 'step_times': self.step_times,
+ 'model_profile': (
+ self.profiler.get_stats() if self.profiler else None
+ ),
+ 'model_cost': None,
+ 'embedding': copy.deepcopy(self.embedding),
+ },
+ ensure_ascii=False,
+ indent=2,
+ ),
+ encoding='utf-8',
+ )
diff --git a/concordia/examples/bellwether/game_test.py b/concordia/examples/bellwether/game_test.py
new file mode 100644
index 000000000..39d3d0141
--- /dev/null
+++ b/concordia/examples/bellwether/game_test.py
@@ -0,0 +1,328 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Fictional rule tests, not live-model or human-subject evidence."""
+
+# Verify the standard policy type; no simulation is launched.
+# pylint: disable=protected-access
+
+import copy
+import json
+import pathlib
+import tempfile
+from unittest import mock
+
+from concordia.agents import entity_agent
+from concordia.components.agent import concat_act_component
+from concordia.components.game_master import make_observation
+from concordia.examples.bellwether import game
+from concordia.examples.bellwether import game_service
+from concordia.utils import operation_service as ops
+import pytest
+
+FULL_SERVICE = [
+ 'ask Nell for fuel and part',
+ 'transfer reserve and part',
+ 'ask Ivo to repair',
+ 'wait',
+ 'order repair',
+ 'promise shelter',
+ 'tell everyone: Let us keep every facility supplied.',
+ 'wait',
+ 'allocate all',
+ 'promise beacon',
+ 'message Mara: The boats have a safe signal.',
+ 'wait',
+]
+PRIORITIZE = [
+ 'allocate shelter and cold store',
+ 'promise shelter',
+ 'tell everyone: Protect shelter and livelihoods.',
+ 'wait',
+ 'allocate shelter and cold store',
+ 'promise shelter',
+ 'wait',
+ 'wait',
+ 'allocate shelter and cold store',
+ 'promise shelter',
+ 'wait',
+ 'wait',
+]
+
+
+def world(dispute=None):
+ value = game.StormNight(
+ game.new_inventory(), make_observation.ObservationQueue(), dispute=dispute
+ )
+ value.seed()
+ return value
+
+
+def drain(value, *, refuse=None, perform=True):
+ while value.data['agenda']:
+ actor = value.next_actor
+ task = value.task()['purpose']
+ decision = 'speak'
+ if task.startswith('request:'):
+ decision = 'decline' if actor == refuse else 'accept'
+ elif task.startswith('work:'):
+ decision = 'perform' if perform else 'decline'
+ value.resolve(
+ actor, json.dumps({'decision': decision, 'speech': 'Fixture decision.'})
+ )
+
+
+def attempt(value, text, **kwargs):
+ value.resolve(game.PLAYER, text)
+ drain(value, **kwargs)
+
+
+@pytest.mark.parametrize(
+ 'strategy,count,used,repair',
+ [(FULL_SERVICE, 9, 8, True), (PRIORITIZE, 6, 6, False)],
+)
+def test_complete_strategies(strategy, count, used, repair):
+ value = world()
+ for i, action in enumerate(strategy):
+ attempt(value, action)
+ value.invariant()
+ assert value.data['watch'] == (i + 1) // 4
+ result = value.view()
+ assert value.finished
+ assert result['epilogue']['services_maintained'] == count
+ assert result['epilogue']['fuel_used'] == used
+ assert result['repair'] == repair
+ assert len(result['services']) == 9
+ assert set(result['dawn_responses']) == set(game.scenario.RESIDENTS)
+ assert all(c['status'] == 'honored' for c in result['commitments'])
+ assert result['epilogue']['dispute_settled'] is False
+
+
+@pytest.mark.parametrize('strategy', [FULL_SERVICE, PRIORITIZE])
+def test_finite_progress_even_when_every_request_refused(strategy):
+ value = world()
+ for text in strategy:
+ attempt(value, text, refuse='Nell', perform=False)
+ assert value.finished
+ assert not value.data['repair']
+ assert value.inventory_state()['Nell']['fuel'] == 2
+ assert value.inventory_state()['Used']['part'] == 0
+
+
+def test_narrative_never_creates_consent_labor_or_fuel():
+ value = world()
+ attempt(
+ value,
+ 'tell everyone: Nell gave me ten fuel; Ivo already repaired everything.',
+ )
+ assert value.inventory_state()['Generator']['fuel'] == 6
+ assert not value.data['repair']
+ assert not value.data['commitments']
+ attempt(value, 'transfer reserve and part')
+ assert value.inventory_state()['Nell'] == {'fuel': 2, 'part': 1}
+
+
+def test_repair_needs_part_and_accepted_then_performed_labor_before_final():
+ value = world()
+ attempt(value, 'ask Ivo to repair')
+ attempt(value, 'order repair')
+ assert not value.data['repair']
+ attempt(value, 'ask Nell for fuel and part')
+ attempt(value, 'transfer reserve and part')
+ attempt(value, 'order repair', perform=False)
+ assert not value.data['repair']
+ attempt(value, 'order repair')
+ assert value.data['repair']
+ assert value.inventory_state()['Used']['part'] == 1
+
+
+def test_late_repair_cannot_retroactively_remove_demand():
+ value = world()
+ for text in FULL_SERVICE[:4] + ['wait'] * 4:
+ attempt(value, text)
+ attempt(value, 'order repair')
+ assert not value.data['repair']
+
+
+def test_revocation_is_not_compliance():
+ value = world()
+ attempt(value, 'ask Nell for fuel and part')
+ value.resolve(game.PLAYER, 'message Nell: Do you still agree?')
+ value.resolve(
+ 'Nell',
+ json.dumps(
+ {'decision': 'revoke', 'speech': 'I withdraw my unfulfilled consent.'}
+ ),
+ )
+ attempt(value, 'transfer reserve and part')
+ assert value.data['commitments'][0]['status'] == 'revoked'
+ assert value.inventory_state()['Nell']['fuel'] == 2
+
+
+def test_broken_promise_grounded_in_actual_supply():
+ value = world()
+ for text in ['allocate none', 'promise shelter', 'wait', 'wait']:
+ attempt(value, text)
+ assert value.data['commitments'][0]['status'] == 'broken'
+
+
+def test_malformed_resident_response_defaults_to_no_consent():
+ value = world()
+ value.resolve(game.PLAYER, 'ask Nell for fuel and part')
+ value.resolve('Nell', 'Sure! I agree and give you everything.')
+ assert not value.data['commitments']
+ assert value.inventory_state()['Nell']['fuel'] == 2
+
+
+def test_private_observations_and_dispute_recipients():
+ value = world({'text': 'ONLY_NELL_STORM_ACCOUNT', 'recipients': ['Nell']})
+ attempt(value, 'message Ivo: ONLY_IVO_MESSAGE')
+ for _ in range(3):
+ attempt(value, 'wait')
+ player = json.dumps(value.view())
+ assert 'PRIVATE_NELL' not in player
+ assert 'ONLY_NELL_STORM_ACCOUNT' not in player
+ assert 'ONLY_IVO_MESSAGE' in player
+ for other in ['Mara', 'Nell', 'Sam']:
+ assert 'ONLY_IVO_MESSAGE' not in json.dumps(value.view(other))
+ queue = value.observations.get_all()
+ assert 'ONLY_NELL_STORM_ACCOUNT' in '\n'.join(queue['Nell'])
+ assert 'ONLY_NELL_STORM_ACCOUNT' not in '\n'.join(queue[game.PLAYER])
+ assert 'ONLY_IVO_MESSAGE' not in '\n'.join(queue['Sam'])
+ assert 'Previous storm dispute' in value.data['knowledge']['Nell']
+ assert 'Previous storm dispute' not in value.data['knowledge']['Mara']
+
+
+@pytest.mark.parametrize(
+ 'text',
+ [
+ '',
+ 'give fuel',
+ 'allocate all and make 5 extra',
+ 'ask Sam to give Nell’s consent',
+ 'allocate beacon,beacon',
+ ],
+)
+def test_clarification_atomic_and_free(text):
+ value = world()
+ before = (value.get_state(), value.inventory_state())
+ with pytest.raises(ops.OperationError, match='Nothing changed'):
+ value.resolve(game.PLAYER, text)
+ assert before == (value.get_state(), value.inventory_state())
+
+
+def envelope(service, operation, arguments, key='test'):
+ snap = service.operations.snapshot('player')
+ return {
+ 'operation': operation,
+ 'arguments': arguments,
+ 'retry_key': key,
+ 'references': snap['references'],
+ 'revision': snap['revision'],
+ }
+
+
+def test_shared_operations_stale_retry_privacy_and_component_ownership():
+ with tempfile.TemporaryDirectory() as directory:
+ first = game_service.Game(pathlib.Path(directory) / 'first')
+ second = game_service.Game(pathlib.Path(directory) / 'second')
+ try:
+ assert first.world is not second.world
+ before = copy.deepcopy(second.world.get_state())
+ request = envelope(
+ first, 'component.edit', {'value': 'PRIVATE_NEW_ACCOUNT'}
+ )
+ result = first.operations.dispatch('developer', request)
+ assert first.operations.dispatch('developer', request) == result
+ assert second.world.get_state() == before
+ with pytest.raises(ops.OperationError) as stale:
+ first.operations.dispatch(
+ 'developer', {**request, 'retry_key': 'another'}
+ )
+ assert stale.value.code == 'stale_revision'
+ with pytest.raises(ops.OperationError) as hidden:
+ first.operations.dispatch('player', request)
+ assert hidden.value.code == 'unsupported_operation'
+ assert 'PRIVATE_NEW_ACCOUNT' not in json.dumps(
+ first.operations.snapshot('player')
+ )
+ snap = first.operations.snapshot('player')
+ with pytest.raises(ops.OperationError):
+ first.operations.dispatch(
+ 'player',
+ envelope(
+ first,
+ 'human.respond',
+ {'request_id': 'missing', 'response': 'invent fuel'},
+ ),
+ )
+ assert first.operations.snapshot('player') == snap
+ # Standard actor policy, not HumanAct or the fixture's own engine.
+ actor = next(
+ e for e in first.simulation.get_entities() if e.name == 'Mara'
+ )
+ assert isinstance(actor, entity_agent.EntityAgent)
+ assert isinstance(
+ actor._act_component, concat_act_component.ConcatActComponent
+ )
+ with mock.patch.object(
+ first.simulation,
+ 'play',
+ side_effect=AssertionError('No simulation in this test'),
+ ):
+ assert first.player_view()['night']['remaining'] == 4
+ finally:
+ first.close()
+ second.close()
+
+
+@pytest.mark.parametrize(
+ 'dispute',
+ [
+ {},
+ [],
+ {'text': 'account', 'recipients': ['unknown']},
+ {'text': 123, 'recipients': []},
+ ],
+)
+def test_invalid_dispute_rejected_before_seeding(dispute):
+ stock = game.new_inventory()
+ observations = make_observation.ObservationQueue()
+ before = copy.deepcopy(stock.get_state())
+ with pytest.raises(ValueError, match='dispute requires'):
+ game.StormNight(stock, observations, dispute=dispute)
+ assert stock.get_state() == before
+ assert not observations.get_all()
+
+
+@pytest.mark.parametrize('style', ['minimal', 'basic'])
+def test_resident_decision_logic_uses_standard_contexts(tmp_path, style):
+ session = game_service.Game(tmp_path, actor_logic=style)
+ try:
+ residents = [
+ e for e in session.simulation.get_entities() if e.name != game.PLAYER
+ ]
+ assert len(residents) == 4
+ for resident in residents:
+ assert isinstance(resident, entity_agent.EntityAgent)
+ assert isinstance(
+ resident._act_component, concat_act_component.ConcatActComponent
+ )
+ components = resident.get_all_context_components()
+ assert ('SelfPerception' in components) == (style == 'basic')
+ assert ('SituationPerception' in components) == (style == 'basic')
+ assert ('PersonBySituation' in components) == (style == 'basic')
+ assert 'Affiliations' in components
+ finally:
+ session.close()
diff --git a/concordia/examples/bellwether/guidance_browser_test.py b/concordia/examples/bellwether/guidance_browser_test.py
new file mode 100644
index 000000000..a6528f9b4
--- /dev/null
+++ b/concordia/examples/bellwether/guidance_browser_test.py
@@ -0,0 +1,212 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""First-play browser checks on real services; no simulation starts."""
+
+import pathlib
+from unittest import mock
+
+from concordia.examples.bellwether import game_service
+from concordia.examples.bellwether import multiplayer
+from concordia.utils import simulation_server
+import pytest
+
+pwlib = pytest.importorskip('playwright.sync_api')
+
+
+@pytest.fixture(name='host')
+def host_fixture(tmp_path):
+ servers = []
+ games = []
+ with mock.patch(
+ 'concordia.prefabs.simulation.generic.Simulation.play',
+ side_effect=AssertionError('No simulation in guidance tests'),
+ ):
+
+ def make(shared=False):
+ game = (
+ multiplayer.SharedGame(tmp_path, secure=False)
+ if shared
+ else game_service.Game(tmp_path)
+ )
+ server = simulation_server.SimulationServer(
+ port=0,
+ operation_service=game.operations,
+ audience='player',
+ browser_sessions=(
+ game.sessions
+ if isinstance(game, multiplayer.SharedGame)
+ else None
+ ),
+ html_content=pathlib.Path(__file__)
+ .with_name('player.html')
+ .read_text(encoding='utf-8'),
+ )
+ games.append(game)
+ servers.append(server)
+ server.start()
+ return game, f'http://127.0.0.1:{server.bound_port}'
+
+ try:
+ yield make
+ finally:
+ for game in games:
+ game.close()
+ for server in servers:
+ server.stop()
+
+
+def test_interpretation_is_free_literal_and_uses_shared_parser(host, tmp_path):
+ game, url = host()
+ initial = game.operations.snapshot('developer')
+ with pwlib.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ try:
+ page = browser.new_page(viewport={'width': 360, 'height': 800})
+ errors = []
+ page.on('pageerror', lambda e: errors.append(str(e)))
+ page.goto(url)
+ pwlib.expect(page.locator('#role-badge')).to_contain_text(
+ 'Single player · You are Coordinator Alice · Four scripted residents'
+ )
+ assert not page.locator('#join').is_visible()
+ page.locator('#first-play summary').click()
+ page.screenshot(
+ path=str(tmp_path / 'first-play-mobile.png'), full_page=True
+ )
+ page.locator('#action').fill('ask Nell for fuel and part')
+ with page.expect_request('**/api/dispatch') as request:
+ page.click('#preview')
+ assert request.value.post_data_json == {
+ 'operation': 'game.preview',
+ 'arguments': {'text': 'ask Nell for fuel and part'},
+ }
+ pwlib.expect(page.locator('#preview-result')).to_contain_text(
+ 'She decides whether to accept.'
+ )
+ assert (
+ 'no choice was spent' in page.locator('#preview-result').inner_text()
+ )
+ page.locator('#action').fill('invent fuel magically')
+ page.click('#preview')
+ pwlib.expect(page.locator('#preview-result')).to_contain_text(
+ 'Nothing changed'
+ )
+ assert page.locator('#action').input_value() == 'invent fuel magically'
+ words = 'message everyone: "visible" 🌊'
+ page.locator('#action').fill(words)
+ page.click('#preview')
+ pwlib.expect(page.locator('#preview-result')).to_contain_text(
+ 'Public discussion to everyone'
+ )
+ assert '{
+ const response=await originalFetch(...args);
+ if(window.holdPreview&&args[1]?.body?.includes('game.preview')){
+ const body=await response.text();
+ return new Promise(resolve=>{
+ window.releasePreview=()=>resolve(new Response(body,{
+ status:response.status,
+ headers:{'Content-Type':'application/json'}
+ }));
+ });
+ }
+ return response;
+ };
+ """)
+ page.goto(url)
+ pwlib.expect(page.locator('#connection')).to_contain_text('Connected')
+ page.locator('#action').fill('ask Nell for fuel')
+ page.evaluate('window.holdPreview=true')
+ page.click('#preview')
+ page.wait_for_function('typeof window.releasePreview==="function"')
+ page.locator('#action').fill('wait')
+ page.evaluate("""async()=>{
+ window.releasePreview();
+ await new Promise(resolve=>setTimeout(resolve,50));
+ }""")
+ pwlib.expect(page.locator('#preview-result')).to_be_empty()
+ page.evaluate('window.holdPreview=false')
+ page.click('#preview')
+ pwlib.expect(page.locator('#preview-result')).to_contain_text(
+ 'Spend one choice waiting when you send'
+ )
+ page.route('**/api/events', lambda route: route.abort('failed'))
+ page.reload()
+ pwlib.expect(page.locator('#connection')).to_contain_text('Reconnecting')
+ pwlib.expect(page.locator('#preview')).to_be_disabled()
+ # Draft is restored only after a session/role snapshot is known.
+ page.unroute('**/api/events')
+ pwlib.expect(page.locator('#connection')).to_contain_text(
+ 'Connected', timeout=15000
+ )
+ pwlib.expect(page.locator('#preview')).to_be_enabled()
+ assert page.locator('#action').input_value() == 'wait'
+ assert game.operations.snapshot('developer') == initial
+ finally:
+ browser.close()
+
+
+@pytest.mark.parametrize('role', ['Nell', 'spectator'])
+def test_other_roles_have_specific_guidance_not_coordinator_preview(host, role):
+ game, url = host(shared=True)
+ assert isinstance(game, multiplayer.SharedGame)
+ with pwlib.sync_playwright() as pw:
+ browser = pw.chromium.launch()
+ try:
+ page = browser.new_page(viewport={'width': 412, 'height': 915})
+ page.goto(url)
+ page.locator('#join-name').fill('Test ' + role)
+ page.select_option('#join-role', role)
+ page.click('#join-submit')
+ pwlib.expect(page.locator('#join-status')).to_contain_text(
+ 'Waiting for host'
+ )
+ row = next(
+ r for r in game.sessions.pending() if r['label'] == 'Test ' + role
+ )
+ game.sessions.approve(row['id'])
+ game.operations.publish({'kind': 'test.host_approved'})
+ pwlib.expect(page.locator('#join')).to_be_hidden()
+ pwlib.expect(page.locator('#preview')).to_be_hidden()
+ page.locator('#first-play summary').click()
+ pwlib.expect(page.locator('#role-guide')).to_contain_text(
+ 'You control Nell' if role == 'Nell' else 'Watch public events'
+ )
+ if role == 'Nell':
+ assert 'Two human roles' in page.locator('#role-badge').inner_text()
+ else:
+ assert not page.locator('#composer').is_visible()
+ finally:
+ browser.close()
diff --git a/concordia/examples/bellwether/live_model_test.py b/concordia/examples/bellwether/live_model_test.py
new file mode 100644
index 000000000..33291d631
--- /dev/null
+++ b/concordia/examples/bellwether/live_model_test.py
@@ -0,0 +1,151 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Live CLI composition with stubbed Ollama transport, not a simulation."""
+
+import json
+from unittest import mock
+
+from concordia.environment.engines import sequential
+from concordia.examples.bellwether import game as rules
+from concordia.examples.bellwether import game_prefab
+from concordia.examples.bellwether import game_service
+from concordia.examples.bellwether import researcher
+from concordia.examples.bellwether import run
+from concordia.prefabs.simulation import generic
+from concordia.typing import entity as entity_lib
+import numpy as np
+import pytest
+
+
+@pytest.mark.parametrize('logic,expected_calls', [('minimal', 1), ('basic', 4)])
+def test_component_prompts_keep_their_own_output_contract(
+ tmp_path, logic, expected_calls
+):
+ built = []
+ original_game = game_service.Game
+ prompts = []
+ formats = []
+ models = []
+
+ class RecordedGame(original_game):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ built.append(self)
+ models.extend([kwargs['model'], kwargs['action_model']])
+
+ def generate(**kwargs):
+ prompt = kwargs['prompt']
+ prompts.append(prompt)
+ formats.append(kwargs.get('format'))
+ assert kwargs['options']['num_predict'] <= 256
+ result = (
+ json.dumps({'decision': 'decline', 'speech': 'I decline this request.'})
+ if kwargs.get('format') == rules.resident_response_schema()
+ else 'a cautious resident considering the available observations.'
+ )
+ return {'response': result}
+
+ with (
+ mock.patch.object(run.ollama_model.ollama, 'Client') as client,
+ mock.patch.object(game_service, 'Game', RecordedGame),
+ mock.patch.object(
+ generic.Simulation, 'play', side_effect=AssertionError('No engine')
+ ),
+ ):
+ client.return_value.generate.side_effect = generate
+ with mock.patch.object(run.time, 'sleep', side_effect=KeyboardInterrupt):
+ run.main([
+ '--mode',
+ 'live',
+ '--resident-prefab',
+ logic,
+ '--editor-port',
+ '0',
+ '--player-port',
+ '0',
+ '--output',
+ str(tmp_path),
+ ])
+ game = built[0]
+ # Unit exercise of one normal actor; no Simulation.play or watch execution.
+ game.world.resolve(rules.PLAYER, 'ask Nell for fuel and part')
+ nell = next(x for x in game.simulation.get_entities() if x.name == 'Nell')
+ nell.observe('The coordinator asks for reserve fuel and the spare part.')
+ result = nell.act(
+ entity_lib.free_action_spec(call_to_action=game.world.action_prompt())
+ )
+ assert rules.parse_resident_response(result)[0] == 'decline'
+ assert json.loads(result)['decision'] == 'decline'
+ assert len(prompts) == expected_calls
+ assert 'Return exactly one JSON object' in prompts[-1]
+ if logic == 'basic':
+ assert all(
+ 'Return exactly one JSON object' not in p for p in prompts[:-1]
+ )
+ assert all('one JSON decision object' not in p for p in prompts[:-1])
+ assert any('What kind of person' in p for p in prompts[:-1])
+ assert any('What situation' in p for p in prompts[:-1])
+ assert formats == [None] * (expected_calls - 1) + [
+ rules.resident_response_schema()
+ ]
+ assert client.call_args_list == [mock.call(timeout=90)] * 2
+ # Exhaust each real standard limiter; no extra provider request is made.
+ for wrapped, limit, already_used in (
+ (models[0], 192, expected_calls - 1),
+ (models[1], 64, 1),
+ ):
+ for _ in range(limit - already_used):
+ wrapped.sample_text('bounded unit request')
+ before = len(prompts)
+ assert wrapped.sample_text('past limit') == ''
+ assert len(prompts) == before
+ assert len(prompts) == 256
+ assert game.simulation.get_raw_log() == []
+
+
+@pytest.mark.parametrize('logic', ['minimal', 'basic'])
+def test_action_model_is_runtime_only_and_human_policy_has_precedence(logic):
+ action_model = mock.Mock(wraps=game_prefab.FixtureModel())
+ action_model.sample_text.return_value = (
+ '{"decision":"decline","speech":"No."}'
+ )
+ human_response = '{"decision":"counter","speech":"My own human terms."}'
+ reader = mock.Mock(return_value=human_response)
+ case = researcher.prepare_case(
+ 'bellwether',
+ lambda request: 'wait',
+ human_readers={'Nell': reader},
+ actor_logic=logic,
+ action_model=action_model,
+ )
+ with mock.patch.object(
+ generic.Simulation, 'play', side_effect=AssertionError('No engine')
+ ):
+ simulation = generic.Simulation(
+ case.config,
+ game_prefab.FixtureModel(),
+ lambda text: np.ones(8),
+ engine=sequential.Sequential(),
+ )
+ actors = {a.name: a for a in simulation.get_entities()}
+ spec = entity_lib.free_action_spec(call_to_action='Return decision JSON.')
+ assert actors['Nell'].act(spec) == human_response
+ reader.assert_called_once()
+ action_model.sample_text.assert_not_called()
+ assert json.loads(actors['Mara'].act(spec))['decision'] == 'decline'
+ action_model.sample_text.assert_called_once()
+ # The live model is captured by the trusted build factory, not params.
+ assert all('action_model' not in i.params for i in case.config.instances)
diff --git a/concordia/examples/bellwether/local_embeddings.py b/concordia/examples/bellwether/local_embeddings.py
new file mode 100644
index 000000000..a49f92a7c
--- /dev/null
+++ b/concordia/examples/bellwether/local_embeddings.py
@@ -0,0 +1,89 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Optional local embeddings for the standard memory-bank callable interface.
+
+No custom retrieval or implicit model downloads. Caller-selected models must
+already be installed in the local Ollama service and support embeddings.
+"""
+
+import contextlib
+import threading
+
+from concordia.utils import profiler as profiler_lib
+import numpy as np
+import ollama
+
+
+class OllamaEmbedder:
+ """Finite normalized vectors with bounded local HTTP and private metrics."""
+
+ def __init__(
+ self,
+ model: str,
+ *,
+ timeout: float = 30,
+ profiler: profiler_lib.ProfilerContext | None = None
+ ):
+ if not isinstance(model, str) or not model.strip():
+ raise ValueError('Choose an installed local embedding model.')
+ if not np.isfinite(timeout) or timeout <= 0:
+ raise ValueError('Embedding timeout must be finite and positive.')
+ self.model = model
+ self._profiler = profiler
+ self._lock = threading.Lock()
+ self._dimensions = None
+ # Do not inherit proxy/remote-host settings for private memory text.
+ self._client = ollama.Client(
+ host='http://127.0.0.1:11434', timeout=timeout, trust_env=False
+ )
+ information = self._client.show(model)
+ if 'embedding' not in (information.get('capabilities') or []):
+ raise ValueError('Selected local model does not support embeddings.')
+
+ def __call__(self, text: str) -> np.ndarray:
+ if not isinstance(text, str):
+ raise ValueError('Memory input must be text.')
+ profile = self._profiler
+ if profile:
+ profile.increment_counter('embedding.requests')
+ timing = profile.track('embedding') if profile else contextlib.nullcontext()
+ try:
+ with timing:
+ response = self._client.embed(
+ model=self.model, input=text, truncate=False, keep_alive='5m'
+ )
+ rows = response.get('embeddings', [])
+ if len(rows) != 1:
+ raise ValueError(
+ 'Embedding response must contain exactly one vector.'
+ )
+ vector = np.asarray(rows[0], dtype=float)
+ if vector.ndim != 1 or not vector.size or not np.isfinite(vector).all():
+ raise ValueError('Embedding must be a finite nonempty vector.')
+ norm = np.linalg.norm(vector)
+ if not np.isfinite(norm) or norm <= 0:
+ raise ValueError('Embedding must have a finite nonzero norm.')
+ with self._lock:
+ if self._dimensions is not None and vector.size != self._dimensions:
+ raise ValueError('Embedding dimensions changed within one run.')
+ self._dimensions = vector.size
+ vector = vector / norm
+ if profile:
+ profile.record_value('embedding.dimensions', float(vector.size))
+ return vector
+ except Exception:
+ if profile:
+ profile.increment_counter('embedding.failures')
+ raise # Never silently replace failed semantic vectors with placeholders.
diff --git a/concordia/examples/bellwether/local_embeddings_test.py b/concordia/examples/bellwether/local_embeddings_test.py
new file mode 100644
index 000000000..78eed0ca0
--- /dev/null
+++ b/concordia/examples/bellwether/local_embeddings_test.py
@@ -0,0 +1,210 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Local vectors, standard memory retrieval and placeholder metadata."""
+
+from unittest import mock
+
+from concordia.associative_memory import basic_associative_memory
+from concordia.examples.bellwether import game_service
+from concordia.examples.bellwether import local_embeddings
+from concordia.examples.bellwether import run
+from concordia.prefabs.simulation import generic
+from concordia.utils import profiler
+import numpy as np
+import pytest
+
+
+@pytest.fixture(name='client')
+def client_fixture():
+ with mock.patch.object(local_embeddings.ollama, 'Client') as factory:
+ factory.return_value.show.return_value = {'capabilities': ['embedding']}
+ yield factory
+
+
+def test_bounded_local_normalized_vectors_and_profile_without_text(client):
+ profile = profiler.ProfilerContext()
+ profile.enable()
+ client.return_value.embed.return_value = {'embeddings': [[3.0, 4.0]]}
+ embed = local_embeddings.OllamaEmbedder(
+ 'installed-embed', timeout=2.5, profiler=profile
+ )
+ client.assert_called_once_with(
+ host='http://127.0.0.1:11434', timeout=2.5, trust_env=False
+ )
+ assert np.allclose(embed('Private fixture memory'), [0.6, 0.8])
+ client.return_value.embed.assert_called_once_with(
+ model='installed-embed',
+ input='Private fixture memory',
+ truncate=False,
+ keep_alive='5m',
+ )
+ stats = profile.get_stats()
+ assert stats['counters']['embedding.requests'] == 1
+ assert 'Private fixture memory' not in str(stats)
+ assert 'embedding' in stats['timings']
+ client.return_value.pull.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ 'rows',
+ [
+ [],
+ [[], []],
+ [[]],
+ [[float('nan')]],
+ [[float('inf')]],
+ [[0.0, 0.0]],
+ [[[1, 2]]],
+ ],
+)
+def test_malformed_vectors_fail_without_placeholder_or_dimension_commit(
+ client, rows
+):
+ client.return_value.embed.side_effect = [
+ {'embeddings': rows},
+ {'embeddings': [[0.0, 1.0]]},
+ ]
+ profile = profiler.ProfilerContext()
+ profile.enable()
+ embed = local_embeddings.OllamaEmbedder('installed-embed', profiler=profile)
+ with pytest.raises(ValueError):
+ embed('fixture')
+ assert np.allclose(embed('valid fixture'), [0, 1])
+ assert profile.get_stats()['counters']['embedding.failures'] == 1
+
+
+def test_dimensions_cannot_change_and_invalid_input_never_reaches_model(client):
+ client.return_value.embed.side_effect = [
+ {'embeddings': [[1, 0]]},
+ {'embeddings': [[1, 0, 0]]},
+ ]
+ embed = local_embeddings.OllamaEmbedder('installed-embed')
+ embed('one')
+ with pytest.raises(ValueError, match='dimensions changed'):
+ embed('two')
+ with pytest.raises(ValueError, match='must be text'):
+ embed(None) # pyrefly: ignore[bad-argument-type] -- invalid-input test
+ assert client.return_value.embed.call_count == 2
+
+
+def test_model_capability_required_without_download_or_memory_transmission(
+ client,
+):
+ client.return_value.show.return_value = {'capabilities': ['completion']}
+ with pytest.raises(ValueError, match='does not support embeddings'):
+ local_embeddings.OllamaEmbedder('chat-only')
+ client.return_value.embed.assert_not_called()
+ client.return_value.pull.assert_not_called()
+
+
+@pytest.mark.parametrize('timeout', [0, -1, float('inf'), float('nan')])
+def test_invalid_request_limits_reject_before_client_creation(client, timeout):
+ with pytest.raises(ValueError):
+ local_embeddings.OllamaEmbedder('installed', timeout=timeout)
+ client.assert_not_called()
+
+
+def test_game_uses_standard_bank_with_supplied_callable_and_keeps_default(
+ tmp_path,
+):
+ seen = []
+
+ def embed(text):
+ seen.append(text)
+ return np.array([1.0, 0.0]) if 'fuel' in text else np.array([0.0, 1.0])
+
+ with mock.patch.object(
+ generic.Simulation, 'play', side_effect=AssertionError('No simulation')
+ ):
+ game = game_service.Game(
+ tmp_path / 'supplied', embedder=embed, actor_logic='basic'
+ )
+ default = game_service.Game(tmp_path / 'default')
+ try:
+ bank = game.simulation.game_master_memory_bank
+ bank.add('A violin is rehearsing.')
+ bank.add('The shelter needs fuel.')
+ assert bank.retrieve_associative('fuel', k=1) == [
+ 'The shelter needs fuel.'
+ ]
+ assert len(seen) == 3
+ assert game.developer_view()['embedding'] == {'kind': 'provided_callable'}
+ assert default.developer_view()['embedding'] == {
+ 'kind': 'constant_placeholder',
+ 'dimensions': 8,
+ }
+ assert 'embedding' not in game.player_view()
+ finally:
+ game.close()
+ default.close()
+
+
+def test_cli_cannot_silently_ignore_embedding_choice_in_slice(client):
+ with pytest.raises(SystemExit) as error:
+ run.main(['--embedding-model', 'installed'])
+ assert error.value.code == 2
+ client.assert_not_called()
+
+
+def test_cli_passes_explicit_embedder_to_existing_game_and_records_model(
+ client, tmp_path
+):
+ built = []
+ original = game_service.Game
+
+ class RecordedGame(original):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ built.append(self)
+
+ with mock.patch.object(game_service, 'Game', RecordedGame), mock.patch.object(
+ generic.Simulation, 'play', side_effect=AssertionError('No run')
+ ), mock.patch.object(run.time, 'sleep', side_effect=KeyboardInterrupt):
+ run.main([
+ '--mode',
+ 'fixture',
+ '--embedding-model',
+ 'installed',
+ '--editor-port',
+ '0',
+ '--player-port',
+ '0',
+ '--output',
+ str(tmp_path),
+ ])
+ assert len(built) == 1
+ assert built[0].embedding == {'kind': 'local_ollama', 'model': 'installed'}
+ client.return_value.show.assert_called_once_with('installed')
+ client.return_value.embed.assert_not_called()
+
+
+def test_transport_failure_leaves_standard_bank_unchanged_and_retries(client):
+ profile = profiler.ProfilerContext()
+ profile.enable()
+ client.return_value.embed.side_effect = [
+ TimeoutError('local request expired'),
+ {'embeddings': [[1.0, 0.0]]},
+ ]
+ embed = local_embeddings.OllamaEmbedder('installed', profiler=profile)
+ bank = basic_associative_memory.AssociativeMemoryBank(embed)
+ before = bank.get_state()
+ with pytest.raises(TimeoutError):
+ bank.add('one memory')
+ assert bank.get_state() == before
+ bank.add('one memory')
+ assert list(bank.get_data_frame()['text']) == ['one memory']
+ assert profile.get_stats()['counters']['embedding.failures'] == 1
+ assert profile.get_stats()['counters']['embedding.requests'] == 2
diff --git a/concordia/examples/bellwether/multiplayer.py b/concordia/examples/bellwether/multiplayer.py
new file mode 100644
index 000000000..b8a372e29
--- /dev/null
+++ b/concordia/examples/bellwether/multiplayer.py
@@ -0,0 +1,190 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Two controlled roles in the existing Bellwether night, not a new engine.
+
+Only the local host approves browsers. A visitor cannot choose its authorized
+role, inspect another inbox or send another actor's response. Other residents
+retain standard prefabs and their normal acting policies.
+"""
+
+import copy
+import json
+
+from concordia.examples.astral_canticle import human_io
+from concordia.examples.bellwether import game
+from concordia.examples.bellwether import game_service
+from concordia.utils import browser_sessions
+from concordia.utils import operation_service as ops
+
+
+class SharedGame(game_service.Game):
+ """Human Coordinator and Nell; AI Mara/Ivo/Sam; read-only public spectator."""
+
+ player_audiences = ('role:Coordinator', 'role:Nell')
+
+ def __init__(self, output, *, secure=True, cookie_path='/', **kwargs):
+ self.sessions = browser_sessions.BrowserSessions(
+ (game.PLAYER, 'Nell', 'spectator'),
+ secure=secure,
+ cookie_path=cookie_path,
+ )
+ self.nell_inbox = human_io.HumanSession(
+ on_request=self._requested, initial_status='Waiting for your turn'
+ )
+ self.response_handlers = {
+ 'role:' + role: lambda args, role=role: self.respond_for(role, args)
+ for role in (game.PLAYER, 'Nell')
+ }
+ self.view_handlers = {
+ 'role:' + role: lambda _, role=role: self.view_for(role)
+ for role in (game.PLAYER, 'Nell')
+ }
+ super().__init__(output, human_readers={'Nell': self.nell_inbox}, **kwargs)
+ self.operations.audience_resolver = self.sessions.audience
+ self.operations.set_view('visitor', lambda: {'lobby': True})
+ for role in (game.PLAYER, 'Nell', 'spectator'):
+ self.operations.set_view(
+ 'role:' + role, lambda role=role: self.view_for(role)
+ )
+
+ def _register(self):
+ super()._register()
+ for name, description, handler in (
+ (
+ 'session.approve',
+ (
+ 'Approve a browser only after confirming the participant with'
+ ' the host.'
+ ),
+ self._approve,
+ ),
+ (
+ 'session.revoke',
+ (
+ 'Revoke a browser role; retain the pending human turn for'
+ ' rejoining.'
+ ),
+ self._revoke,
+ ),
+ ):
+ self.operations.register(
+ ops.Operation(
+ name,
+ description,
+ {
+ 'request_id': ops.Parameter(
+ 'string',
+ 'Pending browser ID from host inspection (not a'
+ ' credential)',
+ )
+ },
+ handler,
+ mutation=True,
+ )
+ )
+
+ def _approve(self, args):
+ result = self.sessions.approve(args['request_id'])
+ self.operations.publish({'kind': 'browser.approved', **result})
+ return result
+
+ def _revoke(self, args):
+ result = self.sessions.revoke(args['request_id'])
+ self.operations.publish({'kind': 'browser.revoked'})
+ return result
+
+ def developer_view(self):
+ return {
+ **super().developer_view(),
+ 'join_requests': self.sessions.pending(),
+ }
+
+ def _start(self, args):
+ if not self.sessions.ready((game.PLAYER, 'Nell')):
+ raise ops.OperationError(
+ 'players_not_ready',
+ 'The host must approve both players before the night begins.',
+ )
+ return super()._start(args)
+
+ def view_for(self, role):
+ state = super().player_view()
+ state['night'] = self.world.view(role)
+ state['role'] = role
+ state['multiplayer'] = True
+ state['players_ready'] = self.sessions.ready((game.PLAYER, 'Nell'))
+ if role == 'spectator':
+ state['human'] = {
+ 'pending': None,
+ 'entries': [],
+ 'finished': self.phase == 'completed',
+ 'status': 'Watching public events',
+ }
+ elif role == 'Nell':
+ state['human'] = self.nell_inbox.snapshot()
+ if state['human']['pending']:
+ task = self.world.task()
+ state['task'] = copy.deepcopy(task)
+ state['human']['pending']['prompt'] = task.get(
+ 'purpose', 'Your response'
+ )
+ purpose = task.get('purpose', '')
+ state['decisions'] = ['speak', 'counter', 'revoke']
+ if purpose.startswith('request:'):
+ state['decisions'] = ['accept', 'decline', 'counter']
+ if purpose.startswith('dawn'):
+ state['decisions'] = ['speak']
+ # No other actor's pending ID, context, task, log or private journal enters
+ # this view. The UI receives a projection, not hidden developer data.
+ return state
+
+ def respond_for(self, role, args):
+ if self.world.next_actor != role:
+ raise ops.OperationError(
+ 'wrong_turn', 'Wait for your own turn; nothing changed.'
+ )
+ if role == game.PLAYER:
+ return super()._respond(args)
+ try:
+ decision, _ = game.parse_resident_response(args['response'])
+ json.loads(
+ args['response']
+ ) # Human replies must be one exact JSON object.
+ purpose = self.world.task().get('purpose', '')
+ allowed = {'speak', 'counter', 'revoke'}
+ if purpose.startswith('request:'):
+ allowed = {'accept', 'decline', 'counter'}
+ if purpose.startswith('dawn'):
+ allowed = {'speak'}
+ if decision not in allowed:
+ raise ValueError('Choose an available decision for this turn.')
+ changed = self.nell_inbox.submit(args['request_id'], args['response'])
+ except (ValueError, TypeError, AttributeError) as error:
+ raise ops.OperationError('invalid_action', str(error)) from error
+ if changed:
+ self.operations.publish({'kind': 'human.accepted', 'actor': role})
+ return {'accepted': changed}
+
+ def _finish(self):
+ self.nell_inbox.finish(
+ 'Dawn has come.'
+ if not self._failure
+ else 'The night stopped; your journal is retained.'
+ )
+ super()._finish()
+
+ def close(self):
+ self.nell_inbox.finish('Service closed.')
+ super().close()
diff --git a/concordia/examples/bellwether/multiplayer_browser_test.py b/concordia/examples/bellwether/multiplayer_browser_test.py
new file mode 100644
index 000000000..3fb8c5615
--- /dev/null
+++ b/concordia/examples/bellwether/multiplayer_browser_test.py
@@ -0,0 +1,209 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Three real browser contexts with synthetic prompts; zero simulation steps."""
+
+import json
+import threading
+
+from concordia.examples.bellwether import game as rules
+from concordia.examples.bellwether.multiplayer_test import envelope
+from concordia.examples.bellwether.multiplayer_test import hosted_fixture # pylint: disable=unused-import
+from concordia.typing import entity as entity_lib
+from concordia.typing import human_input
+from concordia.utils import visual_interface
+import pytest
+
+pw = pytest.importorskip('playwright.sync_api')
+
+
+def join(page, url, name, role):
+ page.goto(url)
+ pw.expect(page.locator('#join')).to_be_visible()
+ page.fill('#join-name', name)
+ page.select_option('#join-role', role)
+ page.click('#join-submit')
+ pw.expect(page.locator('#join-status')).to_contain_text(
+ 'Waiting for host approval'
+ )
+
+
+def approve_in_editor(host, name, revision):
+ pw.expect(host.locator('#op-status')).to_contain_text(f'revision {revision}')
+ if not host.locator('#op-snapshot').evaluate('e=>e.open'):
+ host.locator('#op-snapshot summary').click()
+ host.select_option('#op-preview-field', 'join_requests')
+ host.click('#op-preview-refresh')
+ rows = json.loads(host.locator('#op-state').inner_text())
+ row = next(r for r in rows if r['label'] == name)
+ host.select_option('#op-name', 'session.approve')
+ host.locator('[data-key=request_id]').fill(row['id'])
+ host.click('#op-submit')
+ pw.expect(host.locator('#op-error')).to_have_text('')
+ return row['id']
+
+
+def test_two_players_host_approval_private_turn_reconnect_and_android(
+ hosted, tmp_path
+):
+ game, _, url = hosted
+ game.server.set_html_content(
+ visual_interface.visualize_operations_to_html(game.config)
+ )
+ game.server.start()
+ results = []
+ with pw.sync_playwright() as runner:
+ browser = runner.chromium.launch()
+ cctx = browser.new_context(
+ viewport={'width': 360, 'height': 800}, is_mobile=True, has_touch=True
+ )
+ nctx = browser.new_context(
+ viewport={'width': 412, 'height': 820}, is_mobile=True, has_touch=True
+ )
+ sctx = browser.new_context(
+ viewport={'width': 800, 'height': 360}, has_touch=True
+ )
+ host = browser.new_page()
+ coordinator, nell, spectator = (
+ cctx.new_page(),
+ nctx.new_page(),
+ sctx.new_page(),
+ )
+ pages = [coordinator, nell, spectator, host]
+ errors = []
+ for page in pages:
+ page.on('pageerror', lambda e: errors.append(str(e)))
+ host.goto(f'http://127.0.0.1:{game.server.bound_port}')
+ for page, name, role in [
+ (coordinator, 'A', 'Coordinator'),
+ (nell, 'B', 'Nell'),
+ (spectator, 'C', 'spectator'),
+ ]:
+ join(page, url, name, role)
+ assert 'PRIVATE_' not in page.content()
+ approve_in_editor(
+ host, name, game.operations.snapshot('developer')['revision']
+ )
+ pw.expect(page.locator('#role-badge')).to_contain_text(
+ 'Spectator' if role == 'spectator' else role
+ )
+ for page in [coordinator, nell, spectator]:
+ assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
+ with game.operations.lock:
+ game.world.emit('speech', 'PUBLIC_FOR_ALL')
+ game.world.emit('speech', 'ONLY_COORDINATOR', ['Coordinator'])
+ game.world.emit('speech', 'ONLY_NELL', ['Nell'])
+ game.world.emit('speech', 'ONLY_MARA', ['Mara'])
+ game.operations.publish({'kind': 'synthetic.deliver'})
+ pw.expect(coordinator.locator('#journal')).to_contain_text(
+ 'ONLY_COORDINATOR'
+ )
+ pw.expect(nell.locator('#journal')).to_contain_text('ONLY_NELL')
+ pw.expect(spectator.locator('#journal')).to_contain_text('PUBLIC_FOR_ALL')
+ assert 'ONLY_NELL' not in coordinator.content()
+ assert 'ONLY_COORDINATOR' not in nell.content()
+ assert all(
+ x not in spectator.content()
+ for x in ['ONLY_NELL', 'ONLY_MARA', 'ONLY_COORDINATOR', 'PRIVATE_']
+ )
+ pw.expect(spectator.locator('#composer')).to_be_hidden()
+ game.phase = 'running'
+ game.world.data['agenda'] = [{
+ 'name': 'Nell',
+ 'purpose': 'request: release reserve ' + 'unbroken' * 20,
+ 'audience': rules.NAMES,
+ 'watch': 0,
+ }]
+ request = human_input.HumanInputRequest(
+ request_id='synthetic-nell',
+ entity_name='Nell',
+ action_spec=entity_lib.free_action_spec(call_to_action='Your decision'),
+ contexts={},
+ context='OWN_CONTEXT_NELL',
+ )
+ thread = threading.Thread(
+ target=lambda: results.append(game.nell_inbox(request))
+ )
+ thread.start()
+ try:
+ pw.expect(nell.locator('#act')).to_be_enabled()
+ assert nell.evaluate('document.documentElement.scrollWidth') <= 412
+ pw.expect(coordinator.locator('#act')).to_be_disabled()
+ assert 'OWN_CONTEXT_NELL' not in coordinator.content()
+ nell.select_option('#decision', 'decline')
+ words = 'No — "reserve" is for members. 🌊 '
+ nell.fill('#action', words)
+ nell.reload()
+ pw.expect(nell.locator('#act')).to_be_enabled()
+ assert nell.locator('#action').input_value() == words
+ nell.select_option('#decision', 'decline')
+ nell.route('**/api/events', lambda route: route.abort('failed'))
+ nell.reload()
+ pw.expect(nell.locator('#connection')).to_contain_text('Reconnecting')
+ nell.unroute('**/api/events')
+ pw.expect(nell.locator('#act')).to_be_enabled(timeout=15000)
+ assert nell.locator('#action').input_value() == words
+ nell.select_option('#decision', 'decline')
+ nell.locator('#act').scroll_into_view_if_needed()
+ assert nell.locator('#act').evaluate(
+ 'el => {const r=el.getBoundingClientRect();return el.contains('
+ 'document.elementFromPoint(r.x+r.width/2,r.y+r.height/2))}'
+ )
+ nell.set_viewport_size({'width': 800, 'height': 360})
+ nell.click('#jump-action')
+ assert nell.evaluate('document.documentElement.scrollWidth <= innerWidth')
+ with nell.expect_request('**/api/dispatch') as sent:
+ nell.click('#act')
+ thread.join(2)
+ assert results == [
+ json.dumps(
+ {'decision': 'decline', 'speech': words},
+ separators=(',', ':'),
+ ensure_ascii=False,
+ )
+ ]
+ body = sent.value.post_data_json
+ retried = nell.evaluate(
+ """async body => {
+ const response = await fetch('api/dispatch', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body:JSON.stringify(body)
+ });
+ return await response.json();
+ }""",
+ body,
+ )
+ assert retried['result']['accepted'] is True
+ assert len(results) == 1
+ assert nell.locator('img').count() == 0
+ # Revocation invalidates an already subscribed browser, not just new tabs.
+ row = next(r for r in game.sessions.pending() if r['role'] == 'Nell')
+ game.operations.dispatch(
+ 'developer',
+ envelope(game, 'session.revoke', {'request_id': row['id']}),
+ )
+ pw.expect(nell.locator('#join')).to_be_visible()
+ assert 'OWN_CONTEXT_NELL' not in nell.content()
+ assert 'ONLY_NELL' not in nell.content()
+ coordinator.screenshot(
+ path=str(tmp_path / 'android-coordinator.png'), full_page=True
+ )
+ spectator.screenshot(
+ path=str(tmp_path / 'spectator-landscape.png'), full_page=True
+ )
+ assert not errors
+ finally:
+ game.nell_inbox.finish('done')
+ thread.join(2)
+ browser.close()
diff --git a/concordia/examples/bellwether/multiplayer_test.py b/concordia/examples/bellwether/multiplayer_test.py
new file mode 100644
index 000000000..e82be6c3d
--- /dev/null
+++ b/concordia/examples/bellwether/multiplayer_test.py
@@ -0,0 +1,304 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Privacy and role integrity using real HTTP/SSE; no simulation launches."""
+
+import json
+import pathlib
+import threading
+from unittest import mock
+import uuid
+
+from concordia.examples.bellwether import game as rules
+from concordia.examples.bellwether import multiplayer
+from concordia.typing import entity as entity_lib
+from concordia.typing import human_input
+from concordia.utils import browser_sessions
+from concordia.utils import operation_service as ops
+from concordia.utils import simulation_server
+import httpx
+import pytest
+
+
+def envelope(game, operation, args=None):
+ return dict(
+ operation=operation,
+ arguments=args or {},
+ references=game.operations.references.copy(),
+ revision=game.operations.revision,
+ retry_key=str(uuid.uuid4()),
+ )
+
+
+def approve(game, label):
+ row = next(r for r in game.sessions.pending() if r['label'] == label)
+ game.operations.dispatch(
+ 'developer', envelope(game, 'session.approve', {'request_id': row['id']})
+ )
+ return row['id']
+
+
+@pytest.fixture(name='hosted')
+def hosted_fixture(tmp_path):
+ with mock.patch(
+ 'concordia.prefabs.simulation.generic.Simulation.play',
+ side_effect=AssertionError('No simulation in transport tests'),
+ ):
+ game = multiplayer.SharedGame(tmp_path, secure=False)
+ server = simulation_server.SimulationServer(
+ port=0,
+ operation_service=game.operations,
+ browser_sessions=game.sessions,
+ html_content=pathlib.Path(__file__)
+ .with_name('player.html')
+ .read_text(encoding='utf-8'),
+ )
+ server.start()
+ url = f'http://127.0.0.1:{server.bound_port}'
+ try:
+ yield game, server, url
+ finally:
+ game.close()
+ server.stop()
+
+
+def joined(game, url, role, label=None):
+ client = httpx.Client()
+ client.headers['Origin'] = url
+ assert client.get(url).status_code == 200
+ label = label or role
+ assert (
+ client.post(
+ url + '/api/join', json={'label': label, 'role': role}
+ ).status_code
+ == 200
+ )
+ approve(game, label)
+ return client
+
+
+def test_only_two_humans_default_ai_policies(hosted):
+ game, _, _ = hosted
+ policies = {
+ e.name: type(e.get_act_component()).__name__
+ for e in game.simulation.get_entities()
+ }
+ assert policies == {
+ 'Coordinator': 'HumanActComponent',
+ 'Nell': 'HumanActComponent',
+ 'Mara': 'ConcatActComponent',
+ 'Ivo': 'ConcatActComponent',
+ 'Sam': 'ConcatActComponent',
+ }
+
+
+def test_unauthenticated_forged_and_pending_receive_no_private_data(hosted):
+ game, _, url = hosted
+ for path in ['/api/state', '/api/events', '/api/operations']:
+ assert httpx.get(url + path).status_code == 403
+ visitor = httpx.Client()
+ assert visitor.get(url).status_code == 200
+ for path in ['/api/state', '/api/operations']:
+ assert 'PRIVATE_' not in visitor.get(url + path).text
+ assert visitor.get(url + '/api/state').json()['result'] == {'lobby': True}
+ result = visitor.post(
+ url + '/api/join',
+ json={'label': 'visitor', 'role': 'Nell'},
+ headers={'Origin': url},
+ )
+ assert result.status_code == 200
+ assert visitor.get(url + '/api/state').json()['result'] == {'lobby': True}
+ result = visitor.post(
+ url + '/api/dispatch',
+ json=envelope(
+ game,
+ 'session.approve',
+ {'request_id': game.sessions.pending()[0]['id']},
+ ),
+ headers={'Origin': url},
+ )
+ assert result.status_code == 409
+ assert result.json()['error']['code'] == 'unsupported_operation'
+ for path in ['/events', '/status', '/cmd/set_component_state']:
+ assert visitor.get(url + path).status_code == 404
+
+
+def test_exact_origin_joins_and_mutations(hosted):
+ game, _, url = hosted
+ client = httpx.Client()
+ client.get(url)
+ for origin in [None, 'https://attacker.invalid', url + '.attacker.invalid']:
+ headers = {'Origin': origin} if origin else {}
+ response = client.post(
+ url + '/api/join',
+ json={'label': 'test', 'role': 'Nell'},
+ headers=headers,
+ )
+ assert response.status_code == 403
+ assert not game.sessions.pending()
+
+
+def test_cannot_steal_role_or_inject_developer_or_actor(hosted):
+ game, _, url = hosted
+ joined(game, url, 'Nell')
+ before = game.operations.revision
+ client = httpx.Client()
+ client.get(url)
+ for role in ['Nell', 'developer', 'Mara']:
+ response = client.post(
+ url + '/api/join',
+ json={'label': 'another', 'role': role},
+ headers={'Origin': url},
+ )
+ assert response.status_code == 409
+ assert game.operations.revision == before
+
+
+def test_projections_public_private_spectator_and_revocation(hosted):
+ game, _, url = hosted
+ c = joined(game, url, 'Coordinator')
+ n = joined(game, url, 'Nell')
+ s = joined(game, url, 'spectator')
+ with game.operations.lock:
+ game.world.emit('speech', 'PUBLIC_MARKER')
+ game.world.emit('speech', 'C_ONLY', ['Coordinator'])
+ game.world.emit('speech', 'N_ONLY', ['Nell'])
+ game.world.emit('speech', 'M_ONLY', ['Mara'])
+ game.operations.publish({'kind': 'fixture.observations'})
+ for client, visible, hidden in [
+ (c, ['PUBLIC_MARKER', 'C_ONLY'], ['N_ONLY', 'M_ONLY', 'PRIVATE_NELL']),
+ (
+ n,
+ ['PUBLIC_MARKER', 'N_ONLY', 'PRIVATE_NELL'],
+ ['C_ONLY', 'M_ONLY', 'PRIVATE_MARA'],
+ ),
+ (s, ['PUBLIC_MARKER'], ['C_ONLY', 'N_ONLY', 'M_ONLY', 'PRIVATE_']),
+ ]:
+ content = client.get(url + '/api/state').text
+ assert all(word in content for word in visible)
+ assert all(word not in content for word in hidden)
+ for operation in [
+ 'game.begin',
+ 'human.respond',
+ 'session.approve',
+ 'component.edit',
+ ]:
+ body = envelope(
+ game,
+ operation,
+ {'request_id': 'x', 'response': 'wait'}
+ if operation == 'human.respond'
+ else {},
+ )
+ assert s.post(url + '/api/dispatch', json=body).status_code == 409
+ with n.stream('GET', url + '/api/events', timeout=3) as stream:
+ lines = stream.iter_lines()
+ first = next(lines)
+ assert 'role' in first
+ row = next(r for r in game.sessions.pending() if r['role'] == 'Nell')
+ game.operations.dispatch(
+ 'developer', envelope(game, 'session.revoke', {'request_id': row['id']})
+ )
+ next(lines) # blank SSE separator
+ message = next(lines)
+ assert json.loads(message.removeprefix('data: '))['result'] == {
+ 'lobby': True
+ }
+ assert n.get(url + '/api/state').json()['result'] == {'lobby': True}
+
+
+def test_nell_owns_pending_turn_retry_and_wrong_client_atomic(hosted):
+ game, _, url = hosted
+ c = joined(game, url, 'Coordinator')
+ n = joined(game, url, 'Nell')
+ game.world.data['agenda'] = [{
+ 'name': 'Nell',
+ 'purpose': 'request: release reserve',
+ 'audience': rules.NAMES,
+ 'watch': 0,
+ }]
+ request = human_input.HumanInputRequest(
+ request_id='nell-request',
+ entity_name='Nell',
+ action_spec=entity_lib.free_action_spec(call_to_action='Your decision'),
+ contexts={},
+ context='NELL_PROMPT_ONLY',
+ )
+ result = []
+ thread = threading.Thread(
+ target=lambda: result.append(game.nell_inbox(request))
+ )
+ thread.start()
+ try:
+ assert 'NELL_PROMPT_ONLY' in n.get(url + '/api/state').text
+ assert 'NELL_PROMPT_ONLY' not in c.get(url + '/api/state').text
+ response = json.dumps(
+ {'decision': 'decline', 'speech': 'I keep my reserve.'}
+ )
+ body = envelope(
+ game,
+ 'human.respond',
+ {'request_id': request.request_id, 'response': response},
+ )
+ before = game.operations.revision
+ assert (
+ c.post(url + '/api/dispatch', json=body).json()['error']['code']
+ == 'wrong_turn'
+ )
+ assert game.operations.revision == before
+ bad = envelope(
+ game,
+ 'human.respond',
+ {'request_id': request.request_id, 'response': 'not a decision'},
+ )
+ assert n.post(url + '/api/dispatch', json=bad).status_code == 409
+ assert game.operations.revision == before
+ stale = {**body, 'revision': -1}
+ assert (
+ n.post(url + '/api/dispatch', json=stale).json()['error']['code']
+ == 'stale_revision'
+ )
+ accepted = n.post(url + '/api/dispatch', json=body)
+ assert accepted.status_code == 200
+ thread.join(2)
+ assert result == [response]
+ assert n.post(url + '/api/dispatch', json=body).json() == accepted.json()
+ assert result == [response]
+ finally:
+ game.nell_inbox.finish('done')
+ thread.join(2)
+
+
+def test_cookie_attributes_and_unknown_session_fail_closed():
+
+ store = browser_sessions.BrowserSessions(
+ ('Nell',), cookie_path='/bellwether/'
+ )
+ _, cookie = store.identify('', create=True)
+ assert all(
+ word in cookie
+ for word in ['HttpOnly', 'Secure', 'SameSite=Strict', 'Path=/bellwether/']
+ )
+ with pytest.raises(ops.OperationError):
+ store.identify('unknown=forged')
+
+
+def test_requires_both_players_before_start(hosted):
+ game, _, url = hosted
+ client = joined(game, url, 'Coordinator')
+ response = client.post(
+ url + '/api/dispatch', json=envelope(game, 'game.begin')
+ )
+ assert response.json()['error']['code'] == 'players_not_ready'
+ assert game.developer_view()['quiescent']
diff --git a/concordia/examples/bellwether/player.html b/concordia/examples/bellwether/player.html
new file mode 100644
index 000000000..25a23afa9
--- /dev/null
+++ b/concordia/examples/bellwether/player.html
@@ -0,0 +1,468 @@
+
+
+Last Light at Bellwether
+
Join the night
Choose a role and ask the host to approve this browser. Reloading keeps your place. No private information is shared until approval.
+
Watch 1 · Early eveningFixture · AI residents scripted
+
Last Light at Bellwether
An island community. One stormy night.
+How to play your role
+
Connecting…
+
The island
+
Choose a place. Looking around does not move time.
+Service across the watches
Resolved service, not promises or predictions
Choose a resolved service to see its recorded boundary details. Inspection is free.
+
+
Dawn at Bellwether
+Voice options · optional, on-device only
+
+
Text always works. Dictation is editable and is never sent automatically. Microphone and local language support are checked only when you choose Dictate. No remote speech fallback is used.
+
+
+
Voice is off. Text always works.
+
The night’s account
+Commitments and institutions
+Your available context
+
Keep a public account
+
Save public events and material consequences. Private messages are excluded. Public speech may still identify people. This is not a saved game.
+
+
+
+
+
Waiting to begin
+Review an image note · optional
+
Open a visual-note JSON made with the local image tool. The file is read only in this page, not uploaded. Treat its description as a draft, not verified facts.
+
+Source details
+
Nothing is sent automatically.
+
+
+
diff --git a/concordia/examples/bellwether/public_account.py b/concordia/examples/bellwether/public_account.py
new file mode 100644
index 000000000..804700edb
--- /dev/null
+++ b/concordia/examples/bellwether/public_account.py
@@ -0,0 +1,304 @@
+# Copyright 2026 DeepMind Technologies Limited.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Portable public records, not executable replay or component checkpoints."""
+
+from collections.abc import Mapping
+import copy
+import html
+import json
+
+from concordia.examples.bellwether import game
+from concordia.examples.bellwether import public_figure
+from concordia.utils import operation_service as ops
+
+NOTICE = (
+ 'Public events only. Private journals, component state and service identity'
+ ' are excluded. Public speech can still disclose identifying information;'
+ ' this is not anonymization. This account is not a checkpoint, executable'
+ ' replay, or evidence about real people.'
+)
+
+
+SETUP_NOTICE = (
+ 'Declared teaching setup only, not a complete run configuration.'
+ ' Private component edits, prompts and model settings are not exported;'
+ ' this does not establish deterministic replay or empirical validity.'
+)
+
+
+def setup_text(account: dict) -> str:
+ """Human-readable provenance for both public HTML and the SVG description."""
+ setup = account.get('declared_setup')
+ if setup is None:
+ return 'Setup provenance was not supplied. ' + SETUP_NOTICE
+ return (
+ f"Recipe: {setup['recipe']}. Actor prefab: {setup['actor_logic']}. "
+ + 'Human roles: '
+ + ', '.join(setup['human_roles'])
+ + '. '
+ + f"Engine: {setup['engine']}. "
+ + f"Declared before play: {setup['fuel_consumed_before_play']} fuel"
+ ' used; '
+ + f"{setup['available_fuel_at_start']} fuel initially available. "
+ + SETUP_NOTICE
+ )
+
+
+def document(
+ world: game.StormNight,
+ *,
+ fixture: bool,
+ phase: str,
+ manifest: Mapping | None = None,
+) -> dict:
+ """Project an explicit public allowlist from the standard spectator view."""
+ public = world.view('spectator')
+ epilogue = public['epilogue']
+ return {
+ 'schema': 'bellwether-public-account/v1',
+ 'scope': 'public',
+ 'setup_notice': SETUP_NOTICE,
+ 'declared_setup': (
+ {
+ key: copy.deepcopy(manifest[key])
+ for key in (
+ 'recipe',
+ 'actor_logic',
+ 'human_roles',
+ 'engine',
+ 'fuel_total_including_preconsumed',
+ 'fuel_consumed_before_play',
+ 'available_fuel_at_start',
+ 'evidence_class',
+ )
+ }
+ if manifest is not None
+ else None
+ ),
+ 'backend': 'fixture' if fixture else 'live',
+ 'status': (
+ 'completed'
+ if phase == 'completed'
+ else 'interrupted'
+ if phase == 'failed'
+ else 'in_progress'
+ ),
+ 'watch': public['watch'],
+ 'notice': NOTICE,
+ 'events': [
+ {
+ 'number': index,
+ 'watch': (
+ game.WATCHES[event['watch']] if event['watch'] < 3 else 'Dawn'
+ ),
+ 'kind': event['kind'],
+ 'text': event['text'],
+ }
+ for index, event in enumerate(public['journal'], start=1)
+ ],
+ 'accounting': {
+ 'inventory': {
+ owner: {
+ item: public['inventory'][owner][item]
+ for item in ('fuel', 'part')
+ }
+ for owner in ('Generator', 'Nell', 'Ivo', 'Used')
+ },
+ 'repair_completed': public['repair'],
+ 'services': [
+ {
+ key: record[key]
+ for key in (
+ 'watch',
+ 'facility',
+ 'served',
+ 'consequence',
+ 'demand',
+ )
+ if key in record
+ }
+ | (
+ {
+ 'resolution': {
+ key: record['resolution'][key]
+ for key in (
+ 'basis',
+ 'requested',
+ 'priority',
+ 'fuel_before',
+ 'fuel_spent',
+ 'explanation',
+ )
+ if key in record['resolution']
+ }
+ }
+ if 'resolution' in record
+ else {}
+ )
+ for record in public['services']
+ ],
+ },
+ 'dawn': (
+ {
+ key: epilogue[key]
+ for key in (
+ 'services_maintained',
+ 'services_total',
+ 'fuel_used',
+ 'repair_completed',
+ 'dispute_settled',
+ )
+ }
+ if epilogue
+ else None
+ ),
+ }
+
+
+def render_html(account: dict) -> str:
+ """Render only a projected document as inert, offline UTF-8 HTML."""
+ escape = html.escape
+ events = ''.join(
+ '
'
+ + escape(event['watch'])
+ + '
'
+ + escape(event['text'])
+ + '
'
+ + escape(event['kind'])
+ + '
'
+ for event in account['events']
+ )
+ if not events:
+ events = '
No public events recorded yet.
'
+
+ def cells(values):
+ return ''.join('
' + escape(str(value)) + '
' for value in values)
+
+ material = account['accounting']
+ stocks = ''.join(
+ '
'
+ )
+ dawn = account['dawn']
+ conclusion = (
+ str(dawn['services_maintained'])
+ + ' of '
+ + str(dawn['services_total'])
+ + ' facility-watches supplied. '
+ + str(dawn['fuel_used'])
+ + ' fuel consumed in the cumulative Used ledger (including any declared'
+ ' pre-play consumption). '
+ + (
+ 'The beacon was repaired.'
+ if dawn['repair_completed']
+ else 'The beacon was not repaired.'
+ )
+ + ' These records do not settle the previous-storm dispute.'
+ if dawn
+ else 'No dawn outcome has been recorded yet.'
+ )
+ accounting = (
+ '