game room voice: light the burger voice btn for seated gamers across ROLE/SIG/SKY_SELECT; keep POST's composer inline (OK beside the input) — TDD

Voice (Phase C, room path) — the my_sea voice mesh now extends to the epic game room. room_view sets voice_active for a SEATED gamer (a TableSeat with their gamer) while table_status is in {ROLE_SELECT, SIG_SELECT, SKY_SELECT} — from ROLE_SELECT onset (all tokens committed, seats pre-created by pick_roles) continuously through SKY_SELECT, which hosts the in-page DRAW SEA felt; dark at IN_GAME. voice_room_id = the room UUID (the WebRTC mesh key), voice_muted_at = the persisted mute so an in-arc nav/refresh rejoins muted (mirrors my_sea). The burger fan's shared #id_voice_btn lights .active + carries data-room-id; room.html now includes voice-glow.js (the glow/pulse machine, coexisting w. the sea-btn glow handoff).

No consumer/JS change needed: RoomVoiceConsumer._can_join already gates an epic room on TableSeat membership, the ws/voice/<str:room_id> route serves both mysea-… + bare-UUID keys, and burger-btn.js/voice-mesh.js read data-room-id generically. TDD: RoomVoiceActivationTest (9 ITs — active across the arc, dark at IN_GAME, dark for a non-seated viewer, room-id = UUID, muted-at passthrough) + RoomVoiceConsumerEpicGateTest (2 channels ITs — seated gamer admitted + receives welcome; non-seated refused). 390 epic-view + 11 voice channels ITs green.

- _room.scss: POST composer kept as a flex row — the OK btn sits inline beside the 'Enter a post line' input (the felt/pill chrome stays salvaged in YARN, but the input<->OK row layout is retained; follow-up to 577ef30).

- bundled (parallel work): _room.scss reelhouse h2 font --priUser -> --secUser (SCROLL/POST/PULSE); rootvars.scss chroma-hue primaries brightened (yellow/lime/cyan/indigo/violet/fuschia/magenta).

[[project-my-sea-invite-voice-blueprint]] [[project-character-creation-spec]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-08 19:08:38 -04:00
parent 577ef30f5c
commit 039152a787
6 changed files with 153 additions and 8 deletions

View File

@@ -4564,3 +4564,80 @@ class CarteSeatSwitchSkySeaTest(TestCase):
content = self.client.get(self.room_url + "?seat=3").content.decode() content = self.client.get(self.room_url + "?seat=3").content.decode()
self.assertIn("/sea/save?seat=3", content) self.assertIn("/sea/save?seat=3", content)
self.assertIn("/sky/save?seat=3", content) self.assertIn("/sky/save?seat=3", content)
class RoomVoiceActivationTest(TestCase):
"""Voice activation for the epic game room. Voice is live for a SEATED
gamer across the whole character-creation arc — from ROLE_SELECT onset
(all tokens committed, seats pre-created) continuously through SKY_SELECT
(which hosts the in-page DRAW SEA felt), turning off at IN_GAME. The
burger fan's #id_voice_btn (shared `_burger.html`) lights `.active` +
carries `data-room-id` = the room UUID, which the already-room-aware
RoomVoiceConsumer (`TableSeat` membership gate) + voice-mesh.js consume."""
def setUp(self):
self.founder = User.objects.create(email="vfounder@test.io")
self.room = Room.objects.create(name="Voice Room", owner=self.founder)
self.gamers = [self.founder]
for i in range(2, 7):
self.gamers.append(User.objects.create(email=f"vg{i}@test.io"))
for i, gamer in enumerate(self.gamers, start=1):
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
self.room.gate_status = Room.OPEN
self.room.table_status = Room.ROLE_SELECT
self.room.save()
for i, gamer in enumerate(self.gamers, start=1):
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
def _ctx(self, gamer):
self.client.force_login(gamer)
return self.client.get(self.url).context
def test_voice_active_for_seated_gamer_in_role_select(self):
self.assertIs(self._ctx(self.founder)["voice_active"], True)
def test_voice_room_id_is_room_uuid(self):
self.assertEqual(self._ctx(self.founder)["voice_room_id"], str(self.room.id))
def test_voice_active_persists_through_sig_select(self):
self.room.table_status = Room.SIG_SELECT
self.room.save()
self.assertIs(self._ctx(self.founder)["voice_active"], True)
def test_voice_active_persists_through_sky_select(self):
# SKY_SELECT hosts the in-page DRAW SEA felt — voice spans it too.
self.room.table_status = Room.SKY_SELECT
self.room.save()
self.assertIs(self._ctx(self.founder)["voice_active"], True)
def test_voice_inactive_in_game(self):
self.room.table_status = Room.IN_GAME
self.room.save()
self.assertIs(self._ctx(self.founder)["voice_active"], False)
def test_voice_inactive_for_non_seated_viewer(self):
outsider = User.objects.create(email="outsider@test.io")
self.assertIs(self._ctx(outsider)["voice_active"], False)
def test_voice_muted_at_empty_when_unset(self):
self.assertEqual(self._ctx(self.founder)["voice_muted_at"], "")
def test_voice_muted_at_passed_when_set(self):
self.founder.voice_muted_at = timezone.now()
self.founder.save()
ctx = self._ctx(self.founder)
self.assertEqual(ctx["voice_muted_at"], self.founder.voice_muted_at.isoformat())
def test_voice_btn_renders_active_with_room_id(self):
# The burger fan's voice sub-btn lights .active + carries the room UUID
# (the WebRTC mesh key). NB: .room-page already prints data-room-id, so
# assert the voice-btn's own active markup, not the bare attribute.
self.client.force_login(self.founder)
content = self.client.get(self.url).content.decode()
self.assertIn('class="burger-fan-btn active" aria-label="Voice"', content)
self.assertIn(
f'aria-label="Voice" data-room-id="{self.room.id}"', content)

View File

@@ -800,6 +800,23 @@ def room_view(request, room_id):
seen_seated.add(seat.gamer_id) seen_seated.add(seat.gamer_id)
seated_others.append(seat.gamer) seated_others.append(seat.gamer)
ctx["seated_others"] = seated_others ctx["seated_others"] = seated_others
# Voice (Phase C, room path) — the burger fan's #id_voice_btn lights for a
# SEATED gamer across the whole character-creation arc: ROLE_SELECT onset
# (all tokens committed, seats pre-created by pick_roles) continuously through
# SKY_SELECT (which hosts the in-page DRAW SEA felt), going dark at IN_GAME.
# The room UUID is the WebRTC mesh key — RoomVoiceConsumer._can_join already
# gates an epic room on TableSeat membership (no consumer change), and
# voice-mesh.js / burger-btn.js bind the active click → join/toggle-mute.
# voice_muted_at carries the persisted mute so an in-arc nav/refresh rejoins
# MUTED (mirrors my_sea). [[project-my-sea-invite-voice-blueprint]]
VOICE_PHASES = {Room.ROLE_SELECT, Room.SIG_SELECT, Room.SKY_SELECT}
is_seated = room.table_seats.filter(gamer=request.user).exists()
ctx["voice_active"] = is_seated and room.table_status in VOICE_PHASES
ctx["voice_room_id"] = str(room.id)
ctx["voice_muted_at"] = (
request.user.voice_muted_at.isoformat()
if getattr(request.user, "voice_muted_at", None) else ""
)
return render(request, "apps/gameboard/room.html", ctx) return render(request, "apps/gameboard/room.html", ctx)

View File

@@ -15,6 +15,7 @@ from django.contrib.auth.models import AnonymousUser
from django.test import TransactionTestCase, override_settings, tag from django.test import TransactionTestCase, override_settings, tag
from django.utils import timezone from django.utils import timezone
from apps.epic.models import Room, TableSeat
from apps.gameboard.models import SeaInvite from apps.gameboard.models import SeaInvite
from apps.lyric.models import User from apps.lyric.models import User
from apps.voice.consumers import RoomVoiceConsumer from apps.voice.consumers import RoomVoiceConsumer
@@ -81,6 +82,37 @@ class RoomVoiceConsumerGateTest(TransactionTestCase):
self.assertFalse(connected) self.assertFalse(connected)
@tag("channels")
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class RoomVoiceConsumerEpicGateTest(TransactionTestCase):
"""The epic game-room path of `_can_join`: a SEATED gamer (a TableSeat with
their gamer) is admitted to the room's voice mesh, keyed by the bare room
UUID (the <str:room_id> route serves both `mysea-…` + UUID schemes); a
non-seated user is refused. The consumer needs no my_sea-specific change —
this is the room/multi-seat activation path."""
def setUp(self):
self.seated = User.objects.create(email="seated@test.io", username="seatd")
self.stranger = User.objects.create(email="stranger@test.io", username="strangr")
self.room_obj = Room.objects.create(name="Voice Room", owner=self.seated)
TableSeat.objects.create(room=self.room_obj, gamer=self.seated, slot_number=1)
self.room = str(self.room_obj.id)
async def test_seated_gamer_connects_and_receives_welcome(self):
comm = _comm(self.seated, self.room)
connected, _ = await comm.connect()
self.assertTrue(connected)
msg = await comm.receive_json_from()
self.assertEqual(msg["type"], "welcome")
self.assertIn("peer_id", msg)
await comm.disconnect()
async def test_non_seated_user_is_rejected(self):
comm = _comm(self.stranger, self.room)
connected, _ = await comm.connect()
self.assertFalse(connected)
@tag("channels") @tag("channels")
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) @override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class RoomVoiceConsumerCapacityTest(TransactionTestCase): class RoomVoiceConsumerCapacityTest(TransactionTestCase):

View File

@@ -205,7 +205,7 @@ html.sea-open .room-aperture.is-scrollable {
// SCROLL — provenance feed. --priUser font on a --sixUser strip. // SCROLL — provenance feed. --priUser font on a --sixUser strip.
.room-view--scroll .applet-scroll > h2 { .room-view--scroll .applet-scroll > h2 {
color: rgba(var(--priUser), 1); color: rgba(var(--secUser), 1);
background-color: rgba(var(--sixUser), 1); background-color: rgba(var(--sixUser), 1);
} }
@@ -295,7 +295,7 @@ html.sea-open .room-aperture.is-scrollable {
// --priUser font on a --sepUser strip. // --priUser font on a --sepUser strip.
.room-view--post { .room-view--post {
.applet-scroll > h2 { .applet-scroll > h2 {
color: rgba(var(--priUser), 1); color: rgba(var(--secUser), 1);
background-color: rgba(var(--sepUser), 1); background-color: rgba(var(--sepUser), 1);
} }
@@ -310,11 +310,25 @@ html.sea-open .room-aperture.is-scrollable {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
// Composer stays a flex row: the OK btn sits inline beside the "Enter a
// post line" input (the felt/pill chrome moved to YARN, but the
// input↔OK row layout is retained — user-spec). Same row as New Post.
.post-line-form {
flex-shrink: 0;
.composer-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.composer-row input.form-control { flex: 1; min-width: 0; width: auto; }
}
} }
// PULSE view — forthcoming. --priUser font on an --octUser strip. // PULSE view — forthcoming. --priUser font on an --octUser strip.
.room-view--pulse .applet-scroll > h2 { .room-view--pulse .applet-scroll > h2 {
color: rgba(var(--priUser), 1); color: rgba(var(--secUser), 1);
background-color: rgba(var(--octUser), 1); background-color: rgba(var(--octUser), 1);
} }

View File

@@ -365,9 +365,9 @@
--terUser: var(--priMze); --terUser: var(--priMze);
--quaUser: var(--priPmm); --quaUser: var(--priPmm);
--quiUser: var(--terPmm); --quiUser: var(--terPmm);
--sixUser: var(--priFor); --sixUser: var(--terCfw);
--sepUser: var(--terFor); --sepUser: var(--terMze);
--octUser: var(--priCfw); --octUser: var(--secCfw);
--ninUser: var(--priCtn); --ninUser: var(--priCtn);
--decUser: var(--terCtn); --decUser: var(--terCtn);
} }
@@ -378,8 +378,8 @@
--terUser: var(--priFs); --terUser: var(--priFs);
--quaUser: var(--priCfw); --quaUser: var(--priCfw);
--quiUser: var(--terCfw); --quiUser: var(--terCfw);
--sixUser: var(--terId); --sixUser: var(--secId);
--sepUser: var(--secId); --sepUser: var(--quaId);
--octUser: var(--terFs); --octUser: var(--terFs);
--ninUser: var(--sixPu); --ninUser: var(--sixPu);
--decUser: var(--terPu); --decUser: var(--terPu);

View File

@@ -183,6 +183,11 @@
<script src="{% static 'apps/epic/tray-tooltip.js' %}"></script> <script src="{% static 'apps/epic/tray-tooltip.js' %}"></script>
<script src="{% static 'apps/epic/position-tooltip.js' %}"></script> <script src="{% static 'apps/epic/position-tooltip.js' %}"></script>
<script src="{% static 'apps/epic/burger-btn.js' %}"></script> <script src="{% static 'apps/epic/burger-btn.js' %}"></script>
{# Voice-affordance glow/pulse machine (keys on voice_active + the live mesh #}
{# state) — same as my_sea/my_sea_visit. burger-btn.js owns the click→join; #}
{# voice-mesh.js lazy-loads on first join. Coexists w. the sea-btn glow #}
{# handoff (sea takes burger precedence; voice is the second-place reveal). #}
<script src="{% static 'apps/voice/voice-glow.js' %}"></script>
<script src="{% static 'apps/epic/room-scroll.js' %}"></script> <script src="{% static 'apps/epic/room-scroll.js' %}"></script>
<script src="{% static 'apps/epic/room-views.js' %}"></script> <script src="{% static 'apps/epic/room-views.js' %}"></script>
{% endblock scripts %} {% endblock scripts %}