Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/development-to-staging.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@

---

## Contributors
<!-- Credit anyone who contributed a PR in this batch besides yourself (e.g. @username). -->

## Technical notes
<!-- Migrations, env vars, feature flags, anything a reviewer needs to know -->
3 changes: 3 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/staging-to-main.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@

---

## Contributors
<!-- Credit anyone who contributed a PR in this release besides yourself (e.g. @username). -->

## Technical notes
<!-- Migrations, env vars, feature flags, rollout/rollback risk -->
7 changes: 6 additions & 1 deletion core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,12 @@ def has_add_permission(self, request):
@admin.action(description="Publish selected announcements")
def publish_selected_announcements(_modeladmin, _request, queryset):
now = timezone.now()
queryset.update(is_published=True, published_at=now)
# Save individually (not queryset.update()) so Announcement.save()
# broadcasts the "announcement_published" WebSocket event per row.
for announcement in queryset:
announcement.is_published = True
announcement.published_at = now
announcement.save()


@admin.action(description="Unpublish selected announcements")
Expand Down
24 changes: 24 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,33 @@ def __str__(self):
return self.title

def save(self, *args, **kwargs):
was_published = (
Announcement.objects.filter(pk=self.pk, is_published=True).exists()
if self.pk
else False
)
if self.is_published and self.published_at is None:
self.published_at = timezone.now()
super().save(*args, **kwargs)
if self.is_published and not was_published:
from django.db import transaction

transaction.on_commit(self._broadcast_published)

def _broadcast_published(self):
from asgiref.sync import async_to_sync

from gameplay.utils import send_group_message

async_to_sync(send_group_message)(
"online_users",
{
"type": "action",
"action": "announcement_published",
"data": {"id": self.id},
"success": True,
},
)


class PlayerAnnouncementState(models.Model):
Expand Down
32 changes: 32 additions & 0 deletions docs/operations/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,38 @@ This is a lightweight runbook for backend deployment and immediate verification.
- Prefer additive schema changes prior to destructive cleanups
- Announce deploy windows for higher-risk changes

### Removing a database column safely

`web`, `celery`, and `celery-beat` (and their `-staging` equivalents) are
separate Render services, each with its own `autoDeployTrigger: commit` and
build queue. Only `web` has a `preDeployCommand` that runs `migrate`; `celery`
and `celery-beat` have none. All three deploy independently off the same
commit, with no guarantee they finish building/restarting at the same time —
`celery`/`celery-beat` builds can lag `web` by anywhere from seconds to a
couple of hours depending on Render's build queue.

Because of this, a migration that drops a column in the same deploy as the
code change that stops using it creates a window where `web` has already
migrated the (shared) database but `celery`/`celery-beat` are still running
the *old* image — which still references the now-dropped column — until
their own deploys catch up. Any query touching that model from the old
worker code raises `UndefinedColumn` for the whole window. (This is what
happened with `Character.can_link` on 2026-08-14: the migration removing the
column landed in the same deploy as the code that stopped needing it, and
`commute_tick`/`wander_tick` errored on the stale `celery`/`celery-beat`
workers until they finished redeploying.)

To remove a column without a stale-worker error window, split it across two
separate deploys (expand/contract):

1. **Deploy 1**: ship the code change that stops reading/writing the column,
but leave the column itself in the DB (no migration removing it yet).
2. Confirm `web`, `celery`, and `celery-beat` (and `-staging` equivalents)
have all finished redeploying and are healthy.
3. **Deploy 2**: add the migration that drops the column, with no
accompanying code change needed (the code already stopped touching it in
deploy 1).

## Note on PR workflow

The rule for which branch a *feature* PR should target (normally `development`, with a documented exception for basing on `staging`) is a development workflow concern, not a deployment-execution step — that lives in `CLAUDE.md`, not here.
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
}

@include m.respond-to(sm, down) {
.row {
.row:not(.durationRow) {
flex-direction: column;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export default function LogOfflineActivityModal({ onClose }: LogOfflineActivityM
<span className={styles.label}>
Duration <span className={styles.required} aria-label="required">*</span>
</span>
<div className={styles.row}>
<div className={classNames(styles.row, styles.durationRow)}>
<Input
id="offline-activity-duration-hours"
ariaLabel="Hours"
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/context/WebSocketContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// context/WebSocketContext.tsx
import { useRef, useCallback, useEffect } from 'react';
import type { ReactNode, ReactElement } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useGame } from '../hooks/useGame';
import { useOnlineCount } from './OnlineCountContext';
import { useToast } from '../hooks/useToast';
Expand All @@ -10,6 +11,10 @@ import { handleGlobalWebSocketEvent } from '../websockets/handleGlobalWebSocketE
import { useMaintenanceStatus } from '../hooks/useMaintenanceStatus';
import { useMaintenanceContext } from './MaintenanceContext';
import { WebSocketContext } from './webSocketContext';
import {
ANNOUNCEMENTS_QUERY_KEY,
ANNOUNCEMENT_UNREAD_QUERY_KEY,
} from '../hooks/useAnnouncements';
import type { ActivityTimerApiData, IncomingWebSocketMessage, OutgoingWebSocketMessage } from '../types';

// ---------------------------------------------------------------------------
Expand All @@ -36,6 +41,7 @@ export const WebSocketProvider = ({ children }: ProviderProps): ReactElement =>
const { showToast } = useToast();
const { refetch: maintenanceRefetch } = useMaintenanceStatus();
const { setMaintenance } = useMaintenanceContext();
const queryClient = useQueryClient();
// Set stores message handler callbacks registered by child components
const eventHandlersRef = useRef<Set<(data: IncomingWebSocketMessage) => void>>(new Set());
const wsEnabled = Boolean(!authLoading && isAuthenticated && player?.id);
Expand All @@ -52,14 +58,25 @@ export const WebSocketProvider = ({ children }: ProviderProps): ReactElement =>
});
}, [loadFromServer, player?.is_premium, freeTimerLimitSeconds]);

const onAnnouncementPublished = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ANNOUNCEMENTS_QUERY_KEY });
queryClient.invalidateQueries({ queryKey: ANNOUNCEMENT_UNREAD_QUERY_KEY });
}, [queryClient]);

const onMessage = useCallback((data: IncomingWebSocketMessage) => {
if (data.type === 'online_count') {
setOnlinePlayerCount(data.count);
}
//console.log("[WS Provider] showToast:", showToast);
handleGlobalWebSocketEvent(data, { showToast, maintenanceRefetch, setMaintenance, onActivityTimerUpdate });
handleGlobalWebSocketEvent(data, {
showToast,
maintenanceRefetch,
setMaintenance,
onActivityTimerUpdate,
onAnnouncementPublished,
});
eventHandlersRef.current.forEach((handler) => handler(data));
}, [showToast, maintenanceRefetch, setMaintenance, setOnlinePlayerCount, onActivityTimerUpdate]);
}, [showToast, maintenanceRefetch, setMaintenance, setOnlinePlayerCount, onActivityTimerUpdate, onAnnouncementPublished]);

const onError = useCallback(() => {
console.error('WebSocket connection error');
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/types/timers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export interface WebSocketErrorMessage extends WebSocketMessageBase {
/** Server-initiated action message (maintenance refresh, game events) */
export interface WebSocketActionMessage {
type: "action";
action: "refresh" | "load-game" | "activity_timer_update";
action: "refresh" | "load-game" | "activity_timer_update" | "announcement_published";
message?: string;
maintenance_active?: boolean;
name?: string;
Expand All @@ -168,8 +168,12 @@ export interface WebSocketActionMessage {
* Present when action is "activity_timer_update" — pushed whenever another
* of this player's sessions (tabs/devices) starts, labels, or submits the
* activity timer, so every open session can reconcile to server state.
*
* Present when action is "announcement_published" — the id of the
* newly-published Announcement, so callers can invalidate the
* announcements list and unread-count queries.
*/
data?: { activity_timer: ActivityTimerApiData };
data?: { activity_timer: ActivityTimerApiData } | { id: number };
}

/** Generic server message (currently unused payload) */
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/websockets/handleGlobalWebSocketEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,22 @@ interface HandleGlobalWebSocketEventOptions {
* useActivityTimer's loadFromServer.
*/
onActivityTimerUpdate?: (activityTimer: ActivityTimerApiData) => void;
/**
* Called when the server announces a newly-published Announcement, so the
* caller can refetch the announcements list / unread-count queries.
*/
onAnnouncementPublished?: () => void;
}

export async function handleGlobalWebSocketEvent(
data: IncomingWebSocketMessage,
{ showToast, maintenanceRefetch, setMaintenance, onActivityTimerUpdate }: HandleGlobalWebSocketEventOptions,
{
showToast,
maintenanceRefetch,
setMaintenance,
onActivityTimerUpdate,
onAnnouncementPublished,
}: HandleGlobalWebSocketEventOptions,
): Promise<void> {
switch (data.type) {
case 'notification':
Expand Down Expand Up @@ -74,10 +85,13 @@ export async function handleGlobalWebSocketEvent(
console.log("[WS] Django consumer 'load-game' message not currently in use.");
break;
case 'activity_timer_update':
if (data.data?.activity_timer) {
if (data.data && 'activity_timer' in data.data) {
onActivityTimerUpdate?.(data.data.activity_timer);
}
break;
case 'announcement_published':
onAnnouncementPublished?.();
break;
default:
console.warn('[WS] Unknown action:', data);
}
Expand Down
2 changes: 1 addition & 1 deletion gameworld/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class DailySunTimesAdmin(admin.ModelAdmin):
]
fields = [
"id",
"name",
"date",
("dawn", "dusk"),
("sunrise", "sunset"),
]
Expand Down
49 changes: 29 additions & 20 deletions locations/services/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,52 +14,63 @@
# seconds either way, so the whole village doesn't flip home/work in lockstep.
MAX_STAGGER_SECONDS = 20 * 60

_UNSET = object()


def _stagger_offset_seconds(character_id: int) -> int:
"""Deterministic per-character offset in [-MAX_STAGGER_SECONDS, MAX_STAGGER_SECONDS]."""
span = 2 * MAX_STAGGER_SECONDS + 1
return (character_id % span) - MAX_STAGGER_SECONDS


def work_hours_for(character) -> tuple[time, time]:
def work_hours_for(character, work_location=_UNSET) -> tuple[time, time]:
"""The (open, close) window a character should be at work, from their
assigned work building's open_time/close_time if set, else the fixed
WORK_START/WORK_END constants. Shared by target_role_for (drives
physical movement) and behaviour_services.generate_day (drives the
scheduled CharacterActivity blocks), so a character's actual work
building's hours - e.g. an inn open until 23:00 - govern both rather
than generate_day assuming a fixed 8-17 workday that leaves late
building hours showing as an unrelated leisure/"Relaxing" block."""
building hours showing as an unrelated leisure/"Relaxing" block.

`work_location` may be passed in pre-fetched (e.g. by a caller batching
lookups across many characters, such as commute_tick) to avoid a
per-character query. Left unset, it's looked up here as before.
"""
from character.models import CharacterLocation

work_start, work_end = WORK_START, WORK_END
work_location = (
CharacterLocation.objects.filter(
character=character, role=CharacterLocation.Role.WORK, is_primary=True
if work_location is _UNSET:
work_location = (
CharacterLocation.objects.filter(
character=character, role=CharacterLocation.Role.WORK, is_primary=True
)
.select_related("location")
.first()
)
.select_related("location")
.first()
)
if work_location is not None:
building = work_location.location
if building.open_time is not None and building.close_time is not None:
work_start, work_end = building.open_time, building.close_time
return work_start, work_end


def target_role_for(character, now=None) -> str:
def target_role_for(character, now=None, work_location=_UNSET) -> str:
"""Which role (home/work) a character should currently be at.

The work window comes from the character's assigned work building's
open_time/close_time if set, else falls back to the fixed WORK_START/
WORK_END constants. A per-character stagger is applied to whichever
window is resolved, so the whole village doesn't flip in lockstep."""
window is resolved, so the whole village doesn't flip in lockstep.

`work_location` is forwarded to work_hours_for - see its docstring.
"""
from character.models import CharacterLocation

now = now or timezone.localtime()
seconds_since_midnight = now.hour * 3600 + now.minute * 60 + now.second

work_start, work_end = work_hours_for(character)
work_start, work_end = work_hours_for(character, work_location=work_location)

offset = _stagger_offset_seconds(character.id)
work_start_seconds = work_start.hour * 3600 + work_start.minute * 60 + offset
Expand All @@ -70,29 +81,27 @@ def target_role_for(character, now=None) -> str:
return CharacterLocation.Role.HOME


_UNSET = object()


def sync_character_location(
character, target_location=_UNSET, entrance_node=_UNSET
character, target_role=_UNSET, target_location=_UNSET, entrance_node=_UNSET
) -> None:
"""Compare a character's current/target position against their schedule
and, if they should be elsewhere, send them there via the existing
Journey/set_destination movement stack. No-op if already there, already
heading there, mid-journey, or no matching CharacterLocation/path exists.

`target_location` and `entrance_node` may be passed in pre-fetched (e.g.
by a caller batching lookups across many characters, such as
commute_tick) to avoid a per-character query. Left unset, they're looked
up here as before.
`target_role`, `target_location`, and `entrance_node` may be passed in
pre-fetched (e.g. by a caller batching lookups across many characters,
such as commute_tick) to avoid a per-character query. Left unset,
they're looked up here as before.
"""
from character.models import CharacterLocation
from locations.models import Node

if character.is_moving:
return

target_role = target_role_for(character)
if target_role is _UNSET:
target_role = target_role_for(character)

if target_location is _UNSET:
target_location = (
Expand Down
Loading
Loading