Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
fabe259
Extract beforeunload guard out of useActivityTimer
claude Aug 4, 2026
47edbce
Move DOM event handling out of useEntitySearchInput into its component
claude Aug 5, 2026
1c8a880
Extract XP/multiplier reward breakdown out of ActivityRewardScreen in…
claude Aug 5, 2026
6a8f02f
fix: quote Severity labels in release.yml so GitHub can parse changel…
gaidheal1 Aug 9, 2026
b44c1c5
Extract beforeunload guard out of useActivityTimer
claude Aug 4, 2026
505ea15
Move DOM event handling out of useEntitySearchInput into its component
claude Aug 5, 2026
6dadb06
Merge branch 'claude/issue-573-implementation-wrgq0t' into claude/iss…
gaidheal1 Aug 9, 2026
2b0bf1a
feat: post Discord announcement when a GitHub release is published
gaidheal1 Aug 9, 2026
8586ee7
chore: upgrade pip and npm dependencies
gaidheal1 Aug 9, 2026
96ea048
chore: upgrade frontend dependencies within semver range
gaidheal1 Aug 9, 2026
60b8e88
task: full mypy typing pass
gaidheal1 Aug 9, 2026
4609a7f
Potential fix for pull request finding 'CodeQL / Workflow does not co…
gaidheal1 Aug 9, 2026
2824626
Merge pull request #730 from progressrpg/development
gaidheal1 Aug 9, 2026
990a14f
Extract beforeunload guard out of useActivityTimer
claude Aug 4, 2026
cda8eb8
Merge pull request #680 from progressrpg/claude/issue-572-fix-c99hxy
gaidheal1 Aug 9, 2026
aaf264e
Move DOM event handling out of useEntitySearchInput into its component
claude Aug 5, 2026
d96103e
Merge pull request #697 from progressrpg/claude/issue-573-implementat…
gaidheal1 Aug 9, 2026
9132841
Merge pull request #698 from progressrpg/claude/issue-575-implementation
gaidheal1 Aug 9, 2026
4d0bd13
Consolidate onto a single HTTP stack: migrate api/auth.ts off axios
claude Aug 5, 2026
f5c32df
Merge pull request #699 from progressrpg/claude/issue-576-implementation
gaidheal1 Aug 9, 2026
6ac6589
fix: update Storybook link
gaidheal1 Aug 9, 2026
ffab358
Add BuildingCapability model for multi-role economic buildings
gaidheal1 Aug 9, 2026
6db8a55
Route mill/bakery lookups in capacity_services through BuildingCapabi…
gaidheal1 Aug 9, 2026
5d3b06a
Route mill/bakery ticks through BuildingCapability, key conversion st…
gaidheal1 Aug 9, 2026
e3fcabd
Add Character.total_link_points property (step 1 of capacity sizing p…
gaidheal1 Aug 9, 2026
2f63918
Add link-scaled worker productivity (step 2 of capacity sizing plan)
gaidheal1 Aug 9, 2026
c2feb86
Log settlement_plan recommendation at village generation time (step 3)
gaidheal1 Aug 9, 2026
63bb861
Rename spawn_villages to generate_villages
gaidheal1 Aug 9, 2026
77f4fd7
Implement building capability assignment for mills and bakeries in vi…
gaidheal1 Aug 9, 2026
f6c5203
Merge pull request #736 from progressrpg/feat/building_capabilities
gaidheal1 Aug 9, 2026
43b4ee8
fix: exchange User model create in tests for user_factory
gaidheal1 Aug 9, 2026
0c6f02f
Route mill/bakery ticks through BuildingCapability, key conversion st…
gaidheal1 Aug 9, 2026
b312f48
Merge pull request #734 from progressrpg/feat/building_capabilities_t…
gaidheal1 Aug 9, 2026
be2a048
Add Character.total_link_points property (step 1 of capacity sizing p…
gaidheal1 Aug 9, 2026
ae654bc
Add link-scaled worker productivity (step 2 of capacity sizing plan)
gaidheal1 Aug 9, 2026
b2194c8
Log settlement_plan recommendation at village generation time (step 3)
gaidheal1 Aug 9, 2026
b5dab2d
Rename spawn_villages to generate_villages
gaidheal1 Aug 9, 2026
304fc9a
Implement building capability assignment for mills and bakeries in vi…
gaidheal1 Aug 9, 2026
fed6929
Merge pull request #733 from progressrpg/feat/village_capacity_sizing
gaidheal1 Aug 9, 2026
dd70a05
Merge branch 'feat/village_capacity_sizing' of https://github.com/pro…
gaidheal1 Aug 9, 2026
b95165e
Prevent entrance node creation for granaries in village generation an…
gaidheal1 Aug 9, 2026
35af1ea
Make assign_workers demand-aware (step 5 of capacity sizing plan)
gaidheal1 Aug 9, 2026
5a08eae
Drive milling/baking building packing by population, not slot scarcity
gaidheal1 Aug 9, 2026
45281e1
Display large wheat/flour quantities in tonnes
gaidheal1 Aug 9, 2026
b4e1aec
Group economy_forecast output by population centre, fix unfed verdict
gaidheal1 Aug 9, 2026
d69e0b4
Merge pull request #739 from progressrpg/feat/village_capacity_sizing
gaidheal1 Aug 9, 2026
64f6146
Refactor BuildingDetail component structure and styling; update map t…
gaidheal1 Aug 9, 2026
fa9ba7f
Animate map selection highlight, tie its color to the palette, and ad…
gaidheal1 Aug 9, 2026
2d460f5
Make BuildingDetail's residents/workers lists more compact
gaidheal1 Aug 9, 2026
262465b
Suppress duplicate building hover outline and show concise, contextua…
gaidheal1 Aug 9, 2026
927461f
Reorder character detail facts and narrow the non-modal detail card
gaidheal1 Aug 9, 2026
4fcc78c
Capitalize activity and lower-case location word in character tooltip
gaidheal1 Aug 9, 2026
ae571d5
Default cursor to arrow instead of text-select outside inputs
gaidheal1 Aug 9, 2026
dfd5ed3
Add fly-to button to detail card header and shrink tooltip's View det…
gaidheal1 Aug 9, 2026
f6cfd9e
Keep body/heading/button/link/caption text at their mobile sizes abov…
gaidheal1 Aug 9, 2026
b1dffbe
Dock the map detail panel inside the map and add list hover feedback
gaidheal1 Aug 9, 2026
88caaba
fix: make character outlines smaller radius
gaidheal1 Aug 9, 2026
b603217
Merge pull request #740 from progressrpg/feat/map-selection-highlight…
gaidheal1 Aug 9, 2026
e12199f
Fix character activity schedule to respect the assigned work building…
gaidheal1 Aug 9, 2026
1af244b
Merge pull request #741 from progressrpg/fix/inn-worker-relaxing-acti…
gaidheal1 Aug 9, 2026
8d2f027
Merge pull request #743 from progressrpg/development
gaidheal1 Aug 9, 2026
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
325 changes: 325 additions & 0 deletions .claude/plans/building-capabilities-plan.md

Large diffs are not rendered by default.

293 changes: 293 additions & 0 deletions .claude/plans/village-capacity-sizing-plan.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions .github/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ changelog:
labels:
- bug
- known-issue
- Severity: critical
- Severity: high
- Severity: medium
- Severity: low
- "Severity: critical"
- "Severity: high"
- "Severity: medium"
- "Severity: low"

- title: Developer experience and quality
labels:
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/discord-release-notification.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Announce release on Discord

on:
release:
types: [published]

permissions:
contents: read

jobs:
notify-discord:
runs-on: ubuntu-latest
steps:
- name: Post release to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
RELEASE_NAME: ${{ github.event.release.name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_BODY: ${{ github.event.release.body }}
RELEASE_AUTHOR: ${{ github.event.release.author.login }}
run: |
python3 - <<'PYEOF'
import json
import os
import urllib.request

webhook_url = os.environ["DISCORD_WEBHOOK_URL"]
title = os.environ.get("RELEASE_NAME") or os.environ["RELEASE_TAG"]
body = os.environ.get("RELEASE_BODY") or ""

# Discord embed descriptions are capped at 4096 characters.
max_len = 4000
if len(body) > max_len:
body = body[:max_len].rsplit("\n", 1)[0] + "\n…"

payload = {
"embeds": [
{
"title": f"🚀 New release: {title}",
"url": os.environ["RELEASE_URL"],
"description": body,
"color": 0x5865F2,
"footer": {"text": f"Published by {os.environ.get('RELEASE_AUTHOR', 'unknown')}"},
}
]
}

req = urllib.request.Request(
webhook_url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as resp:
print(resp.status)
PYEOF
9 changes: 9 additions & 0 deletions character/models/character.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,15 @@ def assign_work(self, building: Building):
def has_available(cls):
return character_services.character_has_available(cls)

@property
def total_link_points(self):
"""
Sum of link_points across every player link this character has ever
had (past and current) - the character-side symmetric counterpart to
Player.total_link_points.
"""
return PlayerCharacterLink.total_link_points(self.links.all())


########################################################################
#### PLAYER CHARACTER LINK MODEL
Expand Down
32 changes: 20 additions & 12 deletions character/services/behaviour_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.utils import timezone

from character.utils import window_for_date, work_activities_for
from locations.services.schedule import work_hours_for
from progression.models import ActivityDefinition, CharacterActivity

_FIXED_KINDS = [
Expand Down Expand Up @@ -54,40 +55,47 @@ def aware(dt_date, t: time):
def jitter_minutes(base_dt, minutes):
return base_dt + timedelta(minutes=rng.randint(-minutes, minutes))

sleep_start = aware(date, time(23, 0))
wake = aware(date, time(7, 0))
wake = jitter_minutes(wake, 15)

morning_start = wake
morning_end = morning_start + timedelta(hours=1)

work1_start = morning_end
work1_end = aware(date, time(12, 0))

lunch_start = work1_end
lunch_start = jitter_minutes(lunch_start, 10)
# The work window comes from the character's actual assigned work
# building's hours (same source movement uses - see
# locations.services.schedule.target_role_for) rather than a fixed
# 8-17 assumption, so e.g. an inn open until 23:00 keeps its workers'
# scheduled activity as "working" that late instead of falling through
# to the fixed evening leisure block.
default_work_start, default_work_end = work_hours_for(behaviour.character)
work_start = max(morning_end, aware(date, default_work_start))
work_end = aware(date, default_work_end)

lunch_midpoint = work_start + (work_end - work_start) / 2
lunch_start = jitter_minutes(lunch_midpoint, 10)
lunch_end = lunch_start + timedelta(hours=1)

work1_start = work_start
work1_end = lunch_start
work2_start = lunch_end
work2_end = aware(date, time(17, 0))
work2_end = work_end

dinner_start = aware(date, time(17, 30))
dinner_start = jitter_minutes(dinner_start, 10)
dinner_start = jitter_minutes(max(work_end, aware(date, time(17, 30))), 10)
dinner_end = dinner_start + timedelta(hours=1)

leisure_start = dinner_end
leisure_end = aware(date, time(22, 30))
leisure_end = max(leisure_start, aware(date, time(22, 30)))

wind_start = leisure_end
wind_end = aware(date, time(23, 0))
wind_end = max(wind_start, aware(date, time(23, 0)))

day_window(behaviour, date)

next_day = date + timedelta(days=1)
next_wake = aware(next_day, time(7, 0))
next_wake = jitter_minutes(next_wake, 15)

sleep_start = aware(date, time(23, 0))
sleep_start = wind_end
sleep_end = next_wake

fixed = _fixed_activity_definitions()
Expand Down
2 changes: 1 addition & 1 deletion character/services/relationship_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def relationship_create(relationship_type, members, variant=""):
relationship_type=relationship_type, variant=variant
)

counts = {}
counts: dict[RelationshipRole, int] = {}
for character, role in members:
role = RelationshipRole(role)
CharacterRelationshipMembership.objects.create(
Expand Down
34 changes: 32 additions & 2 deletions character/tests/test_behaviour_services.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from datetime import date
from datetime import date, datetime, time

from django.contrib.gis.geos import Point
from django.test import TestCase
from django.utils import timezone

from character.models import Character
from character.models import Character, CharacterLocation
from character.services.behaviour_services import _FIXED_KINDS
from character.utils import work_activities_for
from locations.models import Building
from progression.models import (
ActivityDefinition,
CharacterActivity,
Expand Down Expand Up @@ -129,6 +131,34 @@ def test_generating_the_same_day_twice_is_deterministic(self):

self.assertEqual(first_ids, second_ids)

def test_late_building_hours_extend_the_work_block_past_the_default_workday(self):
# Inn hours run 06:00-23:00 (see Building.BUILDING_TYPE_HOURS) - well
# past generate_day's old fixed 17:00 work cutoff. An inn worker
# should still be scheduled as "working" in the evening instead of
# falling through to the fixed leisure block (issue: characters
# assigned to the inn showed as "Relaxing" during their shift).
inn = Building.objects.create(
name="The Tipsy Griffin",
building_type="inn",
location=Point(0, 0, srid=3857),
)
CharacterLocation.objects.create(
character=self.character,
location=inn,
role=CharacterLocation.Role.WORK,
is_primary=True,
)

self.character.behaviour.generate_day(date(2026, 1, 5))

evening = timezone.make_aware(datetime.combine(date(2026, 1, 5), time(21, 0)))
activity_at_evening = CharacterActivity.objects.get(
character=self.character,
scheduled_start__lte=evening,
scheduled_end__gt=evening,
)
self.assertEqual(activity_at_evening.activity_definition.kind, "work")


class DeleteDayTests(TestCase):
def setUp(self):
Expand Down
10 changes: 3 additions & 7 deletions character/tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from character.models import Character, PlayerCharacterLink
from character.filters import CharacterFilter
from users.models import CustomUser
from users.tests import user_factory


class CharacterFilterTests(TestCase):
Expand All @@ -32,9 +32,7 @@ def setUp(self):

# Create player characters
# User creation auto-assigns characters, so we need to handle that
self.user1 = CustomUser.objects.create_user(
email="user1@example.com", password="testpass123"
)
self.user1 = user_factory(with_player=True)
# Deactivate auto-assigned character
auto_links = PlayerCharacterLink.objects.filter(
player=self.user1.player, is_active=True
Expand All @@ -55,9 +53,7 @@ def setUp(self):
player=self.user1.player, character=self.player_char1, is_active=True
)

self.user2 = CustomUser.objects.create_user(
email="user2@example.com", password="testpass123"
)
self.user2 = user_factory(with_player=True)
# Deactivate auto-assigned character
auto_links = PlayerCharacterLink.objects.filter(
player=self.user2.player, is_active=True
Expand Down
62 changes: 52 additions & 10 deletions character/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
RELATIONSHIP_SPECS,
)

from users.tests import user_factory


class CharacterRelationshipTests(TestCase):
def setUp(self):
Expand Down Expand Up @@ -423,9 +425,7 @@ def setUp(self):
# Create a player-linked character
# When creating a user, signals automatically create a player and assign a character
# We need to deactivate the auto-assigned link first
self.user = CustomUser.objects.create_user(
email="test@example.com", password="testpass123"
)
self.user = user_factory(with_player=True)
self.player = self.user.player

# Deactivate any auto-assigned character links
Expand Down Expand Up @@ -489,11 +489,10 @@ def test_has_available_no_linkable_characters(self):

def test_has_available_all_linked(self):
"""Test has_available returns False when all linkable characters are linked"""
from users.models import CustomUser
from character.models import PlayerCharacterLink

user1 = CustomUser.objects.create_user(email="user1@test.com", password="pass")
user2 = CustomUser.objects.create_user(email="user2@test.com", password="pass")
user1 = user_factory(with_player=True)
user2 = user_factory(with_player=True)

PlayerCharacterLink.assign_character(player=user1.player, character=self.npc1)
PlayerCharacterLink.assign_character(player=user2.player, character=self.npc2)
Expand All @@ -511,13 +510,10 @@ class PlayerCharacterLinkPointsTodayTests(TestCase):
"""Tests for PlayerCharacterLink.player_time_today/points_today (issue #673)."""

def setUp(self):
from users.models import CustomUser
from progression.models import PlayerActivity

self.PlayerActivity = PlayerActivity
self.user = CustomUser.objects.create_user(
email="today-points@example.com", password="pass12345"
)
self.user = user_factory(with_player=True)
self.player = self.user.player
character = Character.objects.create(given_name="Hero")
self.link = PlayerCharacterLink.objects.create(
Expand Down Expand Up @@ -567,3 +563,49 @@ def test_points_today_excludes_activity_before_link_started(self):
def test_points_today_zero_with_no_activities(self):
self.assertEqual(self.link.player_time_today, 0)
self.assertEqual(self.link.points_today, 0)


class CharacterTotalLinkPointsTests(TestCase):
"""Tests for Character.total_link_points (the character-side counterpart
to Player.total_link_points)."""

def setUp(self):
from users.tests.factories import user_factory

self.character = Character.objects.create(given_name="Hero")
# DecimalField's string default isn't coerced to Decimal until a real
# DB round-trip, so refresh before any test computes link_points
# directly (as opposed to via the DB-backed total_link_points query).
self.character.refresh_from_db()
self.user1 = user_factory(with_player=True)
self.user1.player.refresh_from_db()
self.user2 = user_factory(with_player=True)
self.user2.player.refresh_from_db()

def _make_link(self, player, *, days_linked, unlinked=False):
linked_at = now() - timedelta(days=days_linked)
link = PlayerCharacterLink.objects.create(
player=player, character=self.character, linked_at=linked_at
)
if unlinked:
link.unlinked_at = now()
link.is_active = False
link.save(update_fields=["unlinked_at", "is_active"])
return link

def test_zero_for_a_never_linked_character(self):
never_linked = Character.objects.create(given_name="Loner")
self.assertEqual(never_linked.total_link_points, 0)

def test_sums_a_single_active_link(self):
link = self._make_link(self.user1.player, days_linked=3)
self.assertEqual(self.character.total_link_points, link.link_points)

def test_sums_across_historical_and_active_links(self):
old_link = self._make_link(self.user1.player, days_linked=10, unlinked=True)
current_link = self._make_link(self.user2.player, days_linked=2)

self.assertEqual(
self.character.total_link_points,
old_link.link_points + current_link.link_points,
)
2 changes: 1 addition & 1 deletion character/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def work_activities_for(character):
for activity in ActivityDefinition.objects.filter(
kind=ActivityDefinition.Kind.WORK
).select_related("skill", "skill__role")
if activity.skill_id is None
if activity.skill is None
or (
(activity.skill.role_id is None or activity.skill.role_id in held_role_ids)
and activity.skill.is_unlocked_for(character)
Expand Down
Loading
Loading