44import os
55from builtins import str
66from copy import copy
7+ from typing import Any , Dict , List , Optional , Union
78
89import toml
10+ from pydantic import BaseModel , Field , root_validator
911
1012from shellcraft .utils import to_float , to_list
1113
@@ -119,86 +121,136 @@ def add(self, item):
119121 return new_item
120122
121123
122- class BaseItem (object ):
123- """Abstract class for new items."""
124+ class ResourceAmounts (BaseModel ):
125+ clay : int = 0
126+ ore : int = 0
127+ energy : int = 0
124128
125- _pb = None
126- """This links the item to a serializable Protobuf message that is part of the GameState."""
129+ def get ( self , resource : str , default : int = 0 ) -> int :
130+ return getattr ( self , resource , default )
127131
128- def __init__ (self , name ):
129- """Initialise an empty item."""
130- self .name = name
131- self .difficulty = 0
132- self .description = ""
133- self .prerequisites = {}
134- self .cost = {}
135- self .effects = {}
136- self .strings = {}
137-
138- @classmethod
139- def from_dict (cls , name , data ):
140- """Load an item from dict representation."""
141- item = cls (name )
142-
143- item .description = data .get ("description" , "" )
144- item .difficulty = data .get ("difficulty" , 0 )
145-
146- item .prerequisites = data .get ("prerequisites" , {})
147- item .prerequisites ["items" ] = to_list (item .prerequisites .get ("items" ))
148- item .prerequisites ["research" ] = to_list (item .prerequisites .get ("research" ))
149- item .prerequisites ["triggers" ] = to_list (item .prerequisites .get ("triggers" ))
150- item .cost = data .get ("cost" , {})
151- item .strings = data .get ("strings" , {})
152- item .effects = data .get ("effects" , {})
153- for effect in (
132+ def __iter__ (self ):
133+ yield ("clay" , self .clay )
134+ yield ("ore" , self .ore )
135+ yield ("energy" , self .energy )
136+
137+
138+ class Prerequisites (ResourceAmounts ):
139+ items : List [str ] = Field (default_factory = list )
140+ research : List [str ] = Field (default_factory = list )
141+ triggers : List [str ] = Field (default_factory = list )
142+
143+ @root_validator (pre = True )
144+ def wrap_in_list (cls , values ):
145+ for field in ["items" , "research" , "triggers" ]:
146+ if field in values and not isinstance (values [field ], (list , tuple )):
147+ values [field ] = [values [field ]]
148+ return values
149+
150+
151+ class Effects (ResourceAmounts ):
152+ enable_commands : List [str ] = Field (default_factory = list )
153+ enable_items : List [str ] = Field (default_factory = list )
154+ enable_resources : List [str ] = Field (default_factory = list )
155+ enable_research : List [str ] = Field (default_factory = list )
156+ events : List [str ] = Field (default_factory = list )
157+ triggers : List [str ] = Field (default_factory = list )
158+
159+ # Resources to be granted
160+ clay : int = 0
161+ ore : int = 0
162+ energy : int = 0
163+
164+ @root_validator (pre = True )
165+ def wrap_in_list (cls , values ):
166+ for field in [
154167 "enable_commands" ,
155168 "enable_items" ,
156169 "enable_resources" ,
170+ "enable_research" ,
157171 "events" ,
158172 "triggers" ,
159- ):
160- item .effects [effect ] = to_list (item .effects .get (effect ))
161- return item
173+ ]:
174+ if field in values and not isinstance (values [field ], (list , tuple )):
175+ values [field ] = [values [field ]]
176+ return values
177+
178+
179+ class BaseItem (BaseModel ):
180+ """Pydantic base class for game items."""
181+
182+ name : str
183+ difficulty : float = 0
184+ description : str = ""
185+ prerequisites : Prerequisites = Field (default_factory = Prerequisites )
186+ effects : Effects = Field (default_factory = Effects )
187+ cost : ResourceAmounts = Field (default_factory = ResourceAmounts )
188+ strings : Dict [str , str ] = Field (default_factory = dict )
189+
190+ # Use model_config to allow arbitrary types
191+ model_config = {"arbitrary_types_allowed" : True }
192+
193+ def __init__ (self , name : str = "" , ** data ):
194+ """Initialise an item."""
195+ if name :
196+ data ["name" ] = name
197+ super ().__init__ (** data )
198+ # Set _pb as a private attribute after initialization
199+ self ._pb = None
200+
201+ @classmethod
202+ def from_dict (cls , name , data ):
203+ """Load an item from dict representation."""
204+ # Process prerequisites
205+ data ["name" ] = name
206+ return cls .model_validate (data )
162207
163208 def __repr__ (self ):
164209 """String representation of the item."""
165- return self .name
210+ return str (self .name )
211+
212+ def __str__ (self ):
213+ """String representation of the item."""
214+ return str (self .name )
166215
167216 def __setattr__ (self , key , value ):
168- """Override attribute setter for items with attached message (Protobuf or Pydantic)."""
169- if key != "_pb" and self ._pb is not None :
217+ """Override attribute setter to sync with attached storage model (_pb)."""
218+ # Let Pydantic handle its own fields first
219+ super ().__setattr__ (key , value )
220+
221+ # Also sync to _pb if it exists and the key is a serializable field
222+ if hasattr (self , "_pb" ) and self ._pb is not None and key != "_pb" :
170223 # Check if it's a protobuf object
171224 if hasattr (self ._pb .__class__ , "DESCRIPTOR" ):
172225 if key in self ._pb .__class__ .DESCRIPTOR .fields_by_name .keys ():
173226 setattr (self ._pb , key , value )
174- else :
175- self .__dict__ [key ] = value
176227 # Check if it's a Pydantic model
177228 elif hasattr (self ._pb , "model_fields" ):
178229 if key in self ._pb .model_fields :
179230 setattr (self ._pb , key , value )
180- else :
181- self .__dict__ [key ] = value
182- else :
183- self .__dict__ [key ] = value
184- else :
185- self .__dict__ [key ] = value
186-
187- def __getattr__ (self , key ):
188- """Override attribute getter for items with attached message (Protobuf or Pydantic)."""
189- if key != "_pb" and self ._pb :
190- # Check if it's a protobuf object
191- if hasattr (self ._pb .__class__ , "DESCRIPTOR" ):
192- if key in self ._pb .__class__ .DESCRIPTOR .fields_by_name .keys ():
193- return getattr (self ._pb , key )
194- # Check if it's a Pydantic model
195- elif hasattr (self ._pb , "model_fields" ):
196- if key in self ._pb .model_fields :
197- return getattr (self ._pb , key )
198- raise AttributeError (key )
199231
200-
201- class BaseFactory (object ):
232+ def __getattribute__ (self , key ):
233+ """Override attribute getter with fallback to storage model (_pb) if needed."""
234+ try :
235+ # Try to get from the Pydantic model first
236+ return super ().__getattribute__ (key )
237+ except AttributeError :
238+ # If attribute doesn't exist on the Pydantic model, try _pb
239+ if hasattr (self , "_pb" ) and self ._pb and key != "_pb" :
240+ # Check if it's a protobuf object
241+ if hasattr (self ._pb .__class__ , "DESCRIPTOR" ):
242+ if key in self ._pb .__class__ .DESCRIPTOR .fields_by_name .keys ():
243+ return getattr (self ._pb , key )
244+ # Check if it's a Pydantic model
245+ elif hasattr (self ._pb , "model_fields" ):
246+ if key in self ._pb .model_fields :
247+ return getattr (self ._pb , key )
248+ raise AttributeError (
249+ f"'{ self .__class__ .__name__ } ' object has no attribute '{ key } '"
250+ )
251+
252+
253+ class BaseFactory :
202254 """Factory pattern to instantiate items."""
203255
204256 FIXTURES = "collection.toml"
@@ -220,7 +272,7 @@ def __init__(self, game):
220272 }
221273 self .game = game
222274
223- def get (self , item_name ):
275+ def get (self , item_name ) -> Optional [ BaseItem ] :
224276 """Get an item instance by name."""
225277 if isinstance (item_name , BaseItem ):
226278 return item_name
@@ -251,15 +303,19 @@ def available_items(self):
251303
252304 def _resources_missing_to_craft (self , item_name ):
253305 item = self .get (item_name )
306+ assert item
307+
254308 return {
255309 res : int (res_cost - self .game .resources .get (res ))
256- for res , res_cost in item .cost . items ()
310+ for res , res_cost in item .cost
257311 if res_cost - self .game .resources .get (res ) > 0
258312 }
259313
260314 def can_afford (self , item_name ):
261315 """Return true if we have enough resources to create an item."""
262316 item = self .get (item_name )
317+ assert item
318+
263319 for resource in RESOURCES :
264320 if item .cost .get (resource , 0 ) > self .game .resources .get (resource ):
265321 return False
@@ -269,33 +325,34 @@ def can_afford(self, item_name):
269325 def apply_effects (self , item_name ):
270326 """Apply all effects of an item."""
271327 item = self .get (item_name )
328+ assert item
272329
273330 # Enable commands
274- for command in item .effects .get ( " enable_commands" , []) :
331+ for command in item .effects .enable_commands :
275332 if command not in self .game .state .commands_enabled :
276333 self .game .alert ("You unlocked the `{}` command" , command )
277334 self .game .state .commands_enabled .append (command )
278335
279336 # Enable resouces
280- for resources in item .effects .get ( " enable_resources" , []) :
337+ for resources in item .effects .enable_resources :
281338 if resources not in self .game .state .resources_enabled :
282339 self .game .alert ("You can now mine *{}*." , resources )
283340 self .game .state .resources_enabled .append (resources )
284341
285342 # Enable items
286- for item_name in item .effects .get ( " enable_items" , []) :
343+ for item_name in item .effects .enable_items :
287344 if item_name not in self .game .state .tools_enabled :
288345 self .game .alert ("You can now craft ${}$." , item_name )
289346 self .game .state .tools_enabled .append (item_name )
290347
291348 # Enable research
292- for research in item .effects .get ( " enable_research" , []) :
349+ for research in item .effects .enable_research :
293350 if research not in self .game .state .research_enabled :
294351 self .game .alert ("You can now research @{}@." , research )
295352 self .game .state .research_enabled .append (research )
296353
297354 # Trigger flags
298- for trigger in item .effects .get ( " triggers" , []) :
355+ for trigger in item .effects .triggers :
299356 if trigger not in self .game .state .triggers :
300357 self .game .state .triggers .append (trigger )
301358
@@ -311,7 +368,7 @@ def apply_effects(self, item_name):
311368
312369 # Change mining difficulty
313370 for resource in RESOURCES :
314- change = item .effects . get ( f"{ resource } _mining_difficulty" , None )
371+ change = getattr ( item .effects , f"{ resource } _mining_difficulty" , None )
315372 if change :
316373 change = to_float (change )
317374 self .game .mining_difficulty .multiply (resource , 1 - change )
@@ -320,7 +377,7 @@ def apply_effects(self, item_name):
320377 )
321378
322379 # Trigger events
323- self .game .events .trigger (* item .effects .get ( " events" , []) )
380+ self .game .events .trigger (* item .effects .events )
324381
325382 def is_available (self , item_name ):
326383 """Return true if the prerequisites for an item are met."""
@@ -330,13 +387,13 @@ def is_available(self, item_name):
330387 for resource in RESOURCES :
331388 if self .game .resources .get (resource ) < item .prerequisites .get (resource , 0 ):
332389 return False
333- for required_item in item .prerequisites [ " items" ] :
390+ for required_item in item .prerequisites . items :
334391 if not self .game .has_item (required_item ):
335392 return False
336- for research in item .prerequisites [ " research" ] :
393+ for research in item .prerequisites . research :
337394 if research not in self .game .state .research_completed :
338395 return False
339- for trigger in item .prerequisites [ " triggers" ] :
396+ for trigger in item .prerequisites . triggers :
340397 if trigger not in self .game .state .triggers :
341398 return False
342399 return True
0 commit comments