Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 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
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
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
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.

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
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 docs/architecture/repo-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ locations/
│ ├── setup_world.py
│ ├── show_map.py
│ ├── generate_characters.py
│ └── spawn_villages.py
│ └── generate_villages.py
├── models.py
├── serializers.py
├── services/
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ This documentation covers the project's architecture, development processes, ope
* **[Operations](operations/index.md)** — Configuration, deployment, and maintenance guides.
* **[Development](development/CONTRIBUTING.md)** — Contributor resources and development practices.
* **[Design Notes](design-notes/index.md)** — Ideas and explorations for future features.
* **[Storybook (opens in new tab)](https://progressrpg.github.io/ProgressRPG/){target=_blank}** — Browse reusable UI components, states, and visual examples.
* **[Storybook (opens in new tab)](https://progressrpg.github.io/ProgressRPG/storybook/){target=_blank}** — Browse reusable UI components, states, and visual examples.
- **[API Reference (opens in new tab)](https://web-acgr.onrender.com/api/docs/){target=_blank rel=noopener}** — Explore available endpoints, request formats, and responses using the interactive Swagger documentation.
46 changes: 45 additions & 1 deletion economy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,48 @@ class UnitKind(Enum):
# unbounded.
HUNGER_MAX = 100.0

# Link-scaled worker productivity: a physically-present worker contributes
# 1 (baseline - matches today's flat headcount) plus a bonus derived from
# their character's total_link_points, instead of a flat 1 per worker.
# Capped, not linear, since link_points itself is cumulative and unbounded
# (see PlayerCharacterLink.link_points) - an uncapped multiplier would let a
# long-linked character's output grow forever. ~100 days of a link
# (100 * 20 = 2000 link points from days_linked alone, before any
# login/activity points) reaches the cap - a rough "several months of
# regular play" outer bound pending a real balance pass.
LINK_POINTS_PRODUCTIVITY_SCALE = 2000

# Maximum productivity bonus a single worker's link_points can contribute -
# a fully-maxed worker produces at most 1 + MAX_PRODUCTIVITY_BONUS times the
# unlinked baseline (2x at the default below).
MAX_PRODUCTIVITY_BONUS = 1.0

# A settlement at or below this resident count shares one building for both
# milling and baking (see planning_services.SettlementPlan.
# combine_milling_and_baking) rather than getting a dedicated building per
# role, even when there'd be enough building slots for two - two half-empty
# production buildings isn't a better outcome than one shared one for a
# small village. 30 is an approximate "small village" cutoff pending a real
# balance pass, not derived from another constant.
SMALL_SETTLEMENT_POPULATION_THRESHOLD = 30


def unit_suffix(good_type):
"""Display suffix ("kg"/"L") for a good's quantity, per GOOD_TYPE_UNIT."""
unit = GOOD_TYPE_UNIT.get(good_type, UnitKind.WEIGHT)
return "L" if unit == UnitKind.VOLUME else "kg"


# Grain and flour quantities/rates switch from kg to tonnes past this many
# kg, rather than staying in kg indefinitely - a granary/mill dealing in
# tonnes of wheat or flour is a common, expected scale (unlike bread, which
# stays loaf-counted regardless of size - see _format_bread), so kg alone
# gets unreadable there. Not applied to signed deltas (see _format_default),
# which stay in the plain kg figure.
TONNE_DISPLAY_THRESHOLD_KG = 1000
TONNE_DISPLAY_GOOD_TYPES = {"wheat", "flour"}


# Bread naturally exists as discrete loaves, so it's displayed as a loaf
# count rather than a weight - 1 loaf = 1kg is an exact, not approximate,
# conversion for display purposes.
Expand All @@ -140,10 +175,19 @@ def _format_default(good_type, value, signed=False):
Format a quantity as a plain weight/volume figure. Weight-kind goods are
stored in grams but displayed in kilograms for readability; volume-kind
goods (litres) keep one decimal place, since fractional litres are
meaningful.
meaningful. Unsigned wheat/flour quantities past TONNE_DISPLAY_THRESHOLD_KG
switch to tonnes instead (see TONNE_DISPLAY_GOOD_TYPES).
"""
unit = GOOD_TYPE_UNIT.get(good_type, UnitKind.WEIGHT)
display_value = value if unit == UnitKind.VOLUME else value / 1000

if (
not signed
and good_type in TONNE_DISPLAY_GOOD_TYPES
and display_value >= TONNE_DISPLAY_THRESHOLD_KG
):
return f"{display_value / 1000:,.1f}t"

sign = "+" if signed else ""
return f"{display_value:{sign},.1f}{unit_suffix(good_type)}"

Expand Down
Loading
Loading