Skip to content

Commit d5330b2

Browse files
committed
Implement page accumulator system
1 parent f8c3534 commit d5330b2

File tree

4 files changed

+207
-56
lines changed

4 files changed

+207
-56
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
66

77
## [UNRELEASED]
88

9+
### Added
10+
11+
* Added base classes `PageWithAccumulator`, `AccumulatorPage`, etc. to allow consecutive pages of the same type to be merged into a single page.
12+
13+
### Changed
14+
15+
* `CraftingPage` now extends `PageWithDoubleRecipeAccumulator` instead of `PageWithDoubleRecipe`.
16+
917
### Fixed
1018

1119
* Fixed a validation failure when using [Fabric Resource Conditions](https://github.com/FabricMC/fabric/blob/761f669d0a6fbfe2ae6d71d767651f32a13d37fc/fabric-resource-conditions-api-v1/src/main/java/net/fabricmc/fabric/api/resource/conditions/v1/ResourceConditions.java#L64).

src/hexdoc/patchouli/entry.py

Lines changed: 24 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import logging
2-
from typing import Iterable, Iterator
2+
from typing import Any, Iterable, Iterator
33

44
from pydantic import Field, model_validator
55

66
from hexdoc.core import ItemStack, ResourceLocation
77
from hexdoc.minecraft import LocalizedStr
88
from hexdoc.minecraft.assets import ItemWithTexture, NamedTexture
9-
from hexdoc.minecraft.recipe import CraftingRecipe
109
from hexdoc.model import Color, IDModel
10+
from hexdoc.patchouli.page.abstract_pages import AccumulatorPage, PageWithAccumulator
1111
from hexdoc.utils import Sortable
1212

13-
from .page import CraftingPage, Page, PageWithTitle
14-
from .text import FormatTree
13+
from .page import Page
1514
from .utils import AdvancementSpoilered, Flagged
1615

1716
logger = logging.getLogger(__name__)
@@ -73,48 +72,29 @@ def first_text_page(self):
7372
return page
7473

7574
def preprocess_pages(self) -> Iterator[Page]:
76-
"""Combines adjacent CraftingPage recipes as much as possible."""
77-
accumulator = _CraftingPageAccumulator.blank()
75+
"""Combines adjacent PageWithAccumulator recipes as much as possible."""
76+
acc: AccumulatorPage[Any] | None = None
7877

7978
for page in self.pages:
80-
match page:
81-
case CraftingPage(
82-
recipes=list(recipes),
83-
text=None,
84-
title=None,
85-
anchor=None,
86-
):
87-
accumulator.recipes += recipes
88-
case CraftingPage(
89-
recipes=list(recipes),
90-
title=LocalizedStr() as title,
91-
text=None,
92-
anchor=None,
93-
):
94-
if accumulator.recipes:
95-
yield accumulator
96-
accumulator = _CraftingPageAccumulator.blank()
97-
accumulator.recipes += recipes
98-
accumulator.title = title
99-
case CraftingPage(
100-
recipes=list(recipes),
101-
title=None,
102-
text=FormatTree() as text,
103-
anchor=None,
104-
):
105-
accumulator.title = None
106-
accumulator.text = text
107-
accumulator.recipes += recipes
108-
yield accumulator
109-
accumulator = _CraftingPageAccumulator.blank()
110-
case _:
111-
if accumulator.recipes:
112-
yield accumulator
113-
accumulator = _CraftingPageAccumulator.blank()
114-
yield page
115-
116-
if accumulator.recipes:
117-
yield accumulator
79+
if isinstance(page, PageWithAccumulator):
80+
if not (acc and acc.can_append(page)):
81+
if acc and acc.has_content:
82+
yield acc
83+
acc = page.accumulator_type().from_page(page)
84+
85+
acc.append(page)
86+
87+
if not acc.can_append_more:
88+
yield acc
89+
acc = None
90+
else:
91+
if acc and acc.has_content:
92+
yield acc
93+
acc = None
94+
yield page
95+
96+
if acc and acc.has_content:
97+
yield acc
11898

11999
def _get_advancement(self):
120100
# implements AdvancementSpoilered
@@ -136,11 +116,3 @@ def _skip_disabled_pages(self):
136116
)
137117
self.pages = new_pages
138118
return self
139-
140-
141-
class _CraftingPageAccumulator(PageWithTitle, template_type="patchouli:crafting"):
142-
recipes: list[CraftingRecipe] = Field(default_factory=list)
143-
144-
@classmethod
145-
def blank(cls):
146-
return cls.model_construct()

src/hexdoc/patchouli/page/abstract_pages.py

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
from typing import Any, Generic, Self, TypeVar, Unpack
1+
from __future__ import annotations
22

3-
from pydantic import ConfigDict, model_validator
3+
from abc import ABC, abstractmethod
4+
from typing import Any, ClassVar, Generic, Self, TypeVar, Unpack
5+
6+
from pydantic import ConfigDict, Field, model_validator
47
from pydantic.functional_validators import ModelWrapValidatorHandler
8+
from typing_extensions import override
59

610
from hexdoc.core import ResourceLocation
711
from hexdoc.minecraft import LocalizedStr
@@ -14,6 +18,18 @@
1418

1519
_T_Recipe = TypeVar("_T_Recipe", bound=Recipe)
1620

21+
_T_PageWithAccumulator = TypeVar(
22+
"_T_PageWithAccumulator",
23+
bound="PageWithAccumulator[Any]",
24+
contravariant=True,
25+
)
26+
27+
_T_PageWithRecipeAccumulator = TypeVar(
28+
"_T_PageWithRecipeAccumulator",
29+
bound="PageWithRecipeAccumulator[Any]",
30+
contravariant=True,
31+
)
32+
1733

1834
class Page(TypeTaggedTemplate, AdvancementSpoilered, Flagged, type=None):
1935
"""Base class for Patchouli page types.
@@ -107,3 +123,139 @@ class PageWithDoubleRecipe(PageWithTitle, Generic[_T_Recipe], type=None):
107123
@property
108124
def recipes(self) -> list[_T_Recipe]:
109125
return [r for r in [self.recipe, self.recipe2] if r is not None]
126+
127+
128+
class PageWithAccumulator(Page, ABC, type=None):
129+
"""Base class for pages that can merge together when adjacent."""
130+
131+
@classmethod
132+
@abstractmethod
133+
def accumulator_type(cls) -> type[AccumulatorPage[Any]]:
134+
"""Returns the RecipeAccumulator class for this page type.
135+
136+
The template type of the returned class must match that of this class.
137+
"""
138+
139+
@property
140+
@abstractmethod
141+
def accumulator_title(self) -> LocalizedStr | None:
142+
"""Returns the page's title, if any, for use in the accumulator."""
143+
144+
@property
145+
@abstractmethod
146+
def accumulator_text(self) -> FormatTree | None:
147+
"""Returns the page's text, if any, for use in the accumulator."""
148+
149+
150+
class PageWithRecipeAccumulator(
151+
PageWithAccumulator, ABC, Generic[_T_Recipe], type=None
152+
):
153+
@property
154+
@abstractmethod
155+
def accumulator_recipes(self) -> list[_T_Recipe]:
156+
"""Returns the page's recipes for use in the accumulator."""
157+
158+
159+
class PageWithDoubleRecipeAccumulator(
160+
PageWithDoubleRecipe[_T_Recipe],
161+
PageWithRecipeAccumulator[_T_Recipe],
162+
ABC,
163+
Generic[_T_Recipe],
164+
type=None,
165+
):
166+
@property
167+
@override
168+
def accumulator_title(self) -> LocalizedStr | None:
169+
return self.title
170+
171+
@property
172+
@override
173+
def accumulator_text(self) -> FormatTree | None:
174+
return self.text
175+
176+
@property
177+
@override
178+
def accumulator_recipes(self) -> list[_T_Recipe]:
179+
return self.recipes
180+
181+
182+
class AccumulatorPage(PageWithTitle, ABC, Generic[_T_PageWithAccumulator], type=None):
183+
"""Base class for virtual pages generated to merge adjacent instances of a
184+
PageWithAccumulator page type together."""
185+
186+
_page_type: ClassVar[ResourceLocation | None]
187+
188+
def __init_subclass__(
189+
cls,
190+
*,
191+
page_type: type[PageWithAccumulator[_T_PageWithAccumulator]] | None = None,
192+
**kwargs: Unpack[ConfigDict],
193+
) -> None:
194+
if page_type:
195+
super().__init_subclass__(
196+
type=None,
197+
template_type=str(page_type.template_id),
198+
**kwargs,
199+
)
200+
cls._page_type = page_type.type
201+
else:
202+
super().__init_subclass__(type=None, template_type=None, **kwargs)
203+
cls._page_type = None
204+
205+
@classmethod
206+
def from_page(cls, page: _T_PageWithAccumulator) -> Self:
207+
"""Constructs a new accumulator from the given page.
208+
209+
Note: `append(page)` is always called immediately after this.
210+
"""
211+
if cls._page_type is None:
212+
raise RuntimeError(f"Cannot instantiate {cls} because page_type is None")
213+
if page.type != cls._page_type:
214+
raise ValueError(f"Mismatched page type: {cls}, {page}")
215+
216+
self = cls.model_construct()
217+
self.title = page.accumulator_title
218+
self.anchor = page.anchor
219+
self.advancement = page.advancement
220+
return self
221+
222+
@property
223+
def has_content(self) -> bool:
224+
"""Returns True if this accumulator contains any user-visible content."""
225+
return bool(self.title or self.text)
226+
227+
def can_append(self, page: _T_PageWithAccumulator) -> bool:
228+
"""Returns True if this accumulator can append the given page."""
229+
return (
230+
self._page_type == page.type
231+
and self.title == page.accumulator_title
232+
and self.anchor == page.anchor
233+
and self.advancement == page.advancement
234+
)
235+
236+
@abstractmethod
237+
def append(self, page: _T_PageWithAccumulator):
238+
"""Appends the given page to this accumulator."""
239+
self.text = page.accumulator_text
240+
241+
@property
242+
def can_append_more(self) -> bool:
243+
"""Returns True if this accumulator can append more pages."""
244+
return self.text is None
245+
246+
247+
class RecipeAccumulatorPage(
248+
AccumulatorPage[_T_PageWithRecipeAccumulator],
249+
Generic[_T_PageWithRecipeAccumulator, _T_Recipe],
250+
):
251+
recipes: list[_T_Recipe] = Field(default_factory=lambda: [])
252+
253+
@property
254+
@override
255+
def has_content(self) -> bool:
256+
return super().has_content or bool(self.recipes)
257+
258+
@override
259+
def append(self, page: _T_PageWithRecipeAccumulator):
260+
super().append(page)
261+
self.recipes += page.accumulator_recipes

src/hexdoc/patchouli/page/pages.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
field_validator,
1111
model_validator,
1212
)
13+
from typing_extensions import override
1314

1415
from hexdoc.core import Entity, ItemStack, ResourceLocation
1516
from hexdoc.minecraft import I18n, LocalizedStr
@@ -26,7 +27,14 @@
2627
from hexdoc.model import HexdocModel
2728

2829
from ..text import FormatTree
29-
from .abstract_pages import Page, PageWithDoubleRecipe, PageWithText, PageWithTitle
30+
from .abstract_pages import (
31+
Page,
32+
PageWithDoubleRecipe,
33+
PageWithDoubleRecipeAccumulator,
34+
PageWithText,
35+
PageWithTitle,
36+
RecipeAccumulatorPage,
37+
)
3038

3139

3240
class TextPage(Page, type="patchouli:text"):
@@ -44,7 +52,18 @@ class CampfireCookingPage(
4452
pass
4553

4654

47-
class CraftingPage(PageWithDoubleRecipe[CraftingRecipe], type="patchouli:crafting"):
55+
class CraftingPage(
56+
PageWithDoubleRecipeAccumulator[CraftingRecipe], type="patchouli:crafting"
57+
):
58+
@classmethod
59+
@override
60+
def accumulator_type(cls):
61+
return CraftingAccumulatorPage
62+
63+
64+
class CraftingAccumulatorPage(
65+
RecipeAccumulatorPage[CraftingPage, CraftingRecipe], page_type=CraftingPage
66+
):
4867
pass
4968

5069

0 commit comments

Comments
 (0)