-
Notifications
You must be signed in to change notification settings - Fork 0
5. Implementation
Dato il contesto di sviluppo si è cercato il più possibile di utilizzare il paradigma funzionale. Di seguito sono elencati alcuni aspetti rilevanti utilizzati durante lo sviluppo:
Questo costrutto è risultato molto utile in alcune situazioni per scrivere del codice in maniera concisa e dichiarativa, di seguito vediamo una porzione estratta da ArmyUnit in cui è utilizzato:
for
target <- availableTargets
probabilityOfSuccess <- Seq.fill(availableHits)(Random.nextInt(100))
if probabilityOfSuccess < chanceOfHit
yield hitTarget(target)Questo meccanismo permette di eseguire un match fra un valore e un dato pattern, eseguendo automaticamente i casting e le instanceOf necessarie. Ci è risultato molto utile per migliorare la qualità del codice evitando indentazioni multiple di if-else e rendendolo molto più leggibile. Inoltre il match permette di decomporre un oggetto nelle sue componenti e questo è molto comodo, ad esempio, se abbinato con gli Option perchè possiamo definire i due casi, quello in cui è vuoto e quello in cui è presente, permettendo così di lavorarci direttamente.
Di seguito vediamo un esempio particolare dell'utilizzo di questo costrutto:
events match
case (event: AttackAction.AreaAttackAction) :: tail =>
...
case (event: AttackAction.PrecisionAttackAction) :: tail =>
...
case Seq() => environmentIl tipo Option è utilizzato per descrivere la possibile assenza di un valore, evitando di utilizzare il null. Ad esempio vediamo come il simulationEngine non è ancora pronto quando viene creato il controller delle UI:
private var simulationEngine: Option[SimulationEngine] = NoneQuesto costrutto è utilizzato per gestire valori che potrebbero essere di due tipi diversi. Nel progetto è stato utilizzato per rendere più idiomatica la gestione degli errori e delle eccezioni. Di seguito vediamo un estratto da Validation:
def validate():
Either[List[ValidationError], Unit]Le Given instances sono dei meccanismi che permettono di passare informazioni contestuali al compilatore in maniera automatica, in questo modo il compilatore apprende il valore da usare senza doverlo specificare ogni volta. Nel progetto è risultato particolarmente utile per definire il valore dell'Environment usabile da tutti i componenti. Una qualsiasi funzione che necessita del valore given può utilizzare la parola chiave using per recuperarlo e averlo diponibile.
Permettono di aggiungere uno o più metodi a un classe al di fuori della sua dichiarazione. Questo è particolarmente utile per estendere i tipi implementati da terzi, ad esempio nel nostro progetto sono stati utilizzati per aggiungere il metodo toColor alle String.
extension (field: String)
private[presentation] def toColor: ColorLa programmazione asincrona è stata necessaria in quanto il core della simulazione (ovvero il loop del SimulationEngine) deve essere separato dalla grafica, questo per evitare le durante il tempo di elaborazione la gui non resti bloccata. Per ottenere questo obiettivo è stata scelta la libreria Monix, della quale abbiamo utilizzato i task.
Listenable è stato sviluppato come trait base che contiene i metodi per aggiungere e rimuovere un Listener, in fase di aggiunta viene restituita una istanza di Disposable che facilita la rimozione del Listener.
Per rendere più idiomatica la fase di registrazione è stato creato un metodo onReceiveEvent che memorizza la classe dell'evento (Event) restituendo una istanza di una classe "helper" OnEventPart:
def onReceiveEvent[Event]: OnEventPart[Event] =
OnEventPart()A sua volta la classe OnEventPart espone un metodo from che raccoglie informazioni su che Listenable dobbiamo ascoltare restituendo una istanza di OnEventWithListener:
def from(listenable: Listenable[Event]): OnEventWithListener[Event] =
OnEventWithListener(listenable)Questa infine dopo aver raccolto le informazioni sulla closure da invocare quando c'è un evento si registra sul Listenable
def run(closure: ListenClosure[Event]): Disposable =
listenable.addListener(closure)Questa cascata di classi permette di scrivere il seguente codice:
onReceiveEvent[SimulationEvent] from component run handleEventPer semplificare l'utilizzo di Listenable è stata anche fornito una istanza astratta SimpleListenable che fornisce già una implementazione semplice.
Per implementare il SimulationEngine è stato fatto uso della libreria Monix, questa contiene diversi tipi di entità (Observable, Task...) per semplificare la scrittura di programmi asincroni basati sugli eventi in Scala.
L'idea alla base è che ogni SimulationComponent dato un Environment restituisce un Task che quando viene eseguito restituisce un nuovo environment aggiornato. Quindi, la combinazione dei singoli SimulationComponent è una iterazione della simulazione.
simulationComponents
.foldLeft(Task(previous)) { (task, simulationComponent) =>
task.flatMap(simulationComponent.run)
}A differenza di un Future i Task non si avviano automaticamente quando vengono creati, quindi sarà necessario invocarli come vedremo a breve.
Quando la simulazione viene avviata, ad intervalli regolari (determinati dalla velocità della simulazione) viene avviata una iterazione completa che produce un environment. La simulazione si blocca quando le condizioni di termine della simulazione sono soddisfatte (takeWhileInclusive) o viene cancellata dall'esterno (ogni Task quando viene avviato restituisce un Cancelable che può essere cancellato).
Observable
.intervalAtFixedRate(simulationDayDuration / speed)
.scanEval(Task(currentEnvironment)) { (previous, _) =>
simulationComponents
.foldLeft(Task(previous)) { (task, simulationComponent) =>
task.flatMap(simulationComponent.run)
}
}
.takeWhileInclusive(_.interCountryRelations.hasOngoingWars)Il metodo scanEval ci serve per avere l'environment restituito alla fine dell'iterazione precedente da passare in ingresso all'iterazione successiva.
Le due funzioni che sono state sviluppate in Prolog sono:
- calcolo della distanza tra due punti N-dimensionali;
- ricerca di obiettivi entro un certo range, ordinamento per distanza e filtro dei primi N risultati.
Per quanto riguarda il calcolo della distanza il problema è stato scomposto in due parti:
-
sum_diff_squared(P1, P2, Z): a una lista di coordinate di due puntiP1eP2fa corrispondere inZla somma dei delta delle loro coordinate; -
distance(P1, P2, R): a una lista di coordinate di due puntiP1eP2fa corrispondere inRla radice quadrata disum_diff_squaredovvero la distanza dei punti.
Per quanto riguarda il calcolo dei target il problema è stato scomposto in 6 parti:
-
can_reach(P1, P2, Range): dice se un puntoP2è all'interno di un certo range da P1, per farlo si avvale didistance. -
reachable_targets(P, Range, AllT, ReachableT): a un puntoPfa corrispondere nella listaReachableTtutti i punti presenti nella lista dei targets (AllT) presenti entro un certo raggioRangetramitecan_reach -
sort_targets_per_distance(P, AllT, OrderedT): dato un puntoPad esso fa corrispondere inOrderedTtutti i punti inAllTordinati per distanza da lui usando la quick sort esort_targets_partitioncome predicato di supporto. -
list_limit(L, N, R): dato una listaLe un numero di elementiNad essi fa corrispondere una listaRche contiene al più i primi N elementi diL. -
reachable_targets_limit(P, Range, AllT, Limit, LT): riunisce tutti i predicati sopra in un unico predicato per rendere più semplice la chiamata da Scala
Nello sviluppo di Validation è stato sviluppato un trait Validatable che contiene le informazioni riguardo alla entità che sta venendo validata ValidatableEntity, per usare questo trait è sufficiente implementare un metodo validationErrors a cui far restituire gli eventuali errori di validazione.
Questi ultimi possono essere generati con un semplice DSL creato ad-hoc attraverso l'uso della meta-programmazione, in pratica Validatable contiene un'estensione (di ogni tipo) che aggiunge un metodo must il quale legge il nome della variabile attraverso una macro e lo salva all'interno di ValidationPart assieme a informazioni sulla classe stessa.
extension [Value](field: Value)
transparent inline def must(validator: Validator[Value])(using
entity: ValidatableEntity
): List[ValidationError] =
val part =
ValidationPart[Value](unCamelCaseString(nameOf(field)), field, entity)
validator.validate(part)Questo metodo per funzionare deve essere:
-
inline: questo ci assicura che il codice sia espanso e valutato solo in fase di compilazione; -
transparent: permette al sistema di type-inference del compilatore di andare a vedere dentro questo metodo per ottimizzazioni. altrimentinameOf(field)non restituirebbe più il valore corretto. A questo punto viene creato un insieme di Validator (reperibili inCommonValidators) che permettono di coprire gran parte dei tipici casi d'uso. L'utilizzo di questa strategia ci permette di arrivare al seguente codice finale:
speed must BeGreaterThanOrEqualTo(0.0)il quale, automaticamente, se speed non è maggiore di 0 restituisce l'errore speed must be greater or equal than 0.0 senza la necessità che io gli passi il nome della variabile speed.
- domain
- engine
- simulation
- SimulationEngine
- changeSpeed()
- components
- RelationsSimulationComponent
- WarSimulationComponent
- SimulationEngine
- simulation
- engine
- presentation
- view
- Hud
- displayInitialSimulationConfig()
- highlightText()
- writeToConsole()
- enableSpeed()
- highlightCountryId()
- EndPanel
- MenuActions
- upAction()
- downAction()
- enterAction()
- GameMap
- draw of army units
- GamePanel
- addToPanel()
- Hud
- inputs
- MenuKeyAction
- EnterAction
- controllers
- GameStateController
- setPanel()
- changeSpeed()
- GameStateController
- view
- data
- Serializers
- data_sources
- SimulationEnvironmentDataSource
- repositories
- SimulationEnvironmentRepositoryImpl
- domain
- engine
- simulation
- components
- ResourcesSimulationComponent
- components
- simulation
- model
- Environment
- repositories
- SimulationConfigRepository
- engine
- presentation
- common
- UIConstants
- controllers
- GameStateController
- inputs
- GameMouseMotion
- MenuMouseAdapter
- view
- GameMap
- GamePanel
- ComponentPosition
- Hud
- addJComponents
- uploadJson
- MainFrame
- setPanel
- MenuActions
- getPreferredSize
- invalidate
- paintComponent
- updateMouseAdapter
- MenuHelp
- MenuItemPainter
- SimpleMenuItemPainter
- common
- Launcher
- [root]/.github
- workflows
- release_please.yml
- check_pr.yml
- workflows
- data
- models
- ArmyDtos
- GeomtryDtos
- SimulationEnvironmentDtos
- WorldDtos
- samples
- EncodedSamples
- models
- domain
- engine
- prolog
- PrologEngine
- PrologPredicates
- simulation
- SimulationEngine
- components
- SimulationComponent
- MovementSimulationComponent
- prolog
- model
- common
- validation
- CommonValidators
- Validation
- Disposable
- Geometry (no MultiPolygon)
- Listen
- Math
- Movement
- validation
- fight
- SimulationEvent
- TargetFinderStrategy (no TargetFinderStrategyImpl)
- common
- presentation
- common
- StringToColor
- model
- MenuItems
- common
- engine
- domain
- engine
- simulation
- components
- AttackSimulationComponent
- components
- simulation
- model
- common
- Geometry
- Multypolygon
- Resources
- Geometry
- fight
- Army
- Fight
- TargetFinderStrategy
- TargetFinderStrategyImpl
- world
- Relations
- World
- common
- engine
Mediante la libreria Jacoco abbiamo calcolato la copertura dei test del nostro codice.
Le classi coperte sono il 57% perché purtroppo utilizzare Swing in modalità headless per eseguire test automatici non è banale e quindi abbiamo testato solo i livelli di domain e data.