diff --git a/README.md b/README.md index e12fffe..39da9bc 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,229 @@ -# Galaxy +# Services Architecture -Galaxy is a turn-based strategy game which took place in space. +Galaxy Plus: Turn-based Strategy Game -At the highest level Game has a Backend service and an UI Client. +## Purpose -## Backend service +This document fixes the high-level service architecture of the system. +It is the starting point for implementing the external edge layer, authentication/session management, business services, and push delivery. -Backend service is presented by several "microservices" with a different set of responsibilities. +## Main Principles -- AuthN Proxy Service: handles all incoming requests, - immediately rejects not-authenticated requests and passes authenticated request to the next service. +- The system exposes a single external entry point: **Edge Gateway**. +- Internal business services are **not reachable directly from outside**. +- Any external command, except public auth commands, must be authenticated before it is routed further. +- Gateway handles only edge concerns. Business validation and domain rules remain inside business services. +- Push / long-polling delivery is also handled by the gateway. -- AuthZ Service: checks permissions avaliable to the authenticated user and passes request to the next service - if user is authorized to use specific game api. +## Main Components -- Games Orchestrator Service: - - finds appropriate Game Server according to Client's request data, - - passes user's commands to the selected Game Server, - - reads response and bounces it back up to request chain to the Client, - - manages Game Server state for health monitoring and making next turn. +### 1. Edge Gateway -- Game Servers: several instances of ongoing games with avaliable unprotected API ready to receive Player's and Administrator's commands. +The gateway is the only public entry point for client traffic. -## UI Client +Responsibilities: -UI Client is capable of: +- transport parsing +- authentication of external requests +- transport integrity checks +- session cache lookup +- request signature verification +- timestamp window verification +- anti-replay checks +- rate limiting and abuse protection +- command routing +- basic policy enforcement +- long-polling / push connection handling +- delivery of client-facing events from pub/sub -- Register a new player and login for an existing player using only e-mail and one-time codes, -- Enlist to a new Game from available onboard Games list, -- Request list of Games in which Player participating, -- Request, store and display particular Game data, -- Use push-like mechanism for receiving asynchronous updates from Server, -- Offline mode when no internet connection is available or user desired to work offline. +The gateway must not implement domain-specific business logic. + +### 2. Auth / Session Service + +This service owns authentication and device session lifecycle. + +Responsibilities: + +- `send_email_code` +- `confirm_email_code` +- device session creation +- public key registration for device sessions +- session revoke / logout +- persistence of session state +- publishing session state changes for cache invalidation/update + +This service is the source of truth for `device_session` state. + +### 3. Session Store + +Persistent storage for device sessions. + +Typical fields: + +- `device_session_id` +- `user_id` +- client public key +- session status +- creation / revoke timestamps +- optional client metadata + +### 4. Session Cache + +Fast lookup cache used by the gateway. + +Purpose: + +- resolve `device_session_id -> user_id + public_key + status` +- avoid synchronous calls from gateway to auth/session service on every request + +Cache updates should be driven by session lifecycle events and may also use TTL. + +### 5. Anti-Replay Store + +Edge-level storage for recently seen transport `request_id` values. + +Purpose: + +- reject replayed authenticated transport messages within the allowed time window + +This is transport-level replay protection, not business idempotency. + +### 6. Rate Limit Store + +Shared state for edge-level throttling and abuse control. + +Typical dimensions: + +- IP / network +- `device_session_id` +- `user_id` +- command class + +### 7. Business Services + +Internal services that process authenticated commands. + +Responsibilities: + +- business validation +- authorization by `user_id` +- ownership checks +- domain invariants +- state transitions +- business idempotency where required +- publishing domain events and/or client-facing events + +Business services do not verify external signatures and do not access external clients directly. + +### 8. Event Bus / Pub-Sub + +Used for internal event distribution. + +Purposes: + +- session cache invalidation/update +- client-facing event delivery through the gateway +- optional internal domain event propagation between services + +## External Flows + +### Public Auth Flow + +These commands are public and do not require an existing device session: + +- `send_email_code` +- `confirm_email_code` + +Flow: + +1. client sends public auth command to gateway +2. gateway applies public-edge checks (format, rate limits, abuse policy) +3. gateway routes command to auth/session service +4. auth/session service performs auth logic +5. if login is confirmed, auth/session service creates `device_session` +6. auth/session service publishes cache update/invalidation event + +### Authenticated Command Flow + +All other external commands require authentication. + +Flow: + +1. client sends authenticated request to gateway +2. gateway validates transport envelope presence and protocol version +3. gateway resolves `device_session_id` through session cache +4. gateway rejects unknown or revoked sessions +5. gateway verifies request signature +6. gateway verifies timestamp window +7. gateway verifies anti-replay constraints +8. gateway applies rate limits and basic policy checks +9. gateway extracts authenticated context, including `user_id` +10. gateway routes the request to the target business service based on `command_type` + +No business service should receive an unauthenticated external request. + +### Push / Long-Polling Flow + +The gateway owns external push / long-polling connections. + +Flow: + +1. client opens authenticated push / long-polling connection through gateway +2. gateway binds connection to `user_id` and `device_session_id` +3. gateway may send current server time for clock offset calculation +4. internal services publish client-facing events to pub/sub +5. gateway consumes those events and delivers them to the proper client connections + +Gateway is a delivery layer, not the source of business events. + +## Internal Contract Between Gateway and Business Services + +Business services should receive an internal authenticated command, not raw external transport data. + +Typical internal authenticated context: + +- `user_id` +- `device_session_id` +- `command_type` +- verified payload bytes +- transport `request_id` +- optional command id / trace id +- optional client metadata relevant for logging + +Business services must trust only the gateway as their external ingress. + +## Separation of Responsibilities + +### Gateway is responsible for + +- who sent the request +- whether transport integrity is valid +- whether the request is fresh +- whether replay is detected +- whether request volume is acceptable +- where to route the request + +### Business services are responsible for + +- whether the user is allowed to perform the business action +- whether the target object belongs to the user +- whether the domain state transition is valid +- whether business idempotency rules are satisfied + +## Revocation Behavior + +When a device session is revoked: + +- auth/session service updates the source of truth +- auth/session service publishes revoke/invalidation event +- gateway updates or invalidates session cache +- gateway rejects further requests for that session +- gateway closes active push / long-polling connections bound to that session, if applicable + +## Non-Goals + +The gateway is not a place for full domain authorization logic. +It must not become a business “god service”. + +The auth/session service is not the hot path for every authenticated request. +The gateway should authenticate most requests from cache, not by synchronous round-trips. diff --git a/README_security.md b/README_security.md new file mode 100644 index 0000000..d02fcb9 --- /dev/null +++ b/README_security.md @@ -0,0 +1,204 @@ +# Secure Exchange Architecture + +## Purpose + +This document fixes the transport-level secure exchange model between client and server. +It is the starting point for implementing authenticated device sessions, signed requests/responses, and anti-replay protection. + +## Main Principles + +- No browser cookies are used. +- Authentication is device-session based. +- Each device/session is unique and independently revocable. +- There are no short-lived access tokens or refresh-token flows in the main design. +- Requests are authenticated by client-side signatures. +- Responses are authenticated by server-side signatures. +- Transport integrity and freshness are verified before payload is processed. + +## Device Session Model + +After successful login through e-mail code: + +1. client generates an asymmetric key pair +2. private key remains on the client device +3. public key is registered on the server +4. server creates a persistent `device_session` +5. client stores: + - `device_session_id` + - private key + +The server stores at least: + +- `device_session_id` +- `user_id` +- client public key +- session status +- revoke metadata + +## Key Storage + +### Native Clients + +Private key should be stored in platform secure storage. + +### Browser / WASM Clients + +Private key should be created and used through WebCrypto. +Non-exportable key storage is preferred. +Loss of browser storage is acceptable and means re-login is required. + +## Request Structure + +Each authenticated request logically contains: + +- `payload_bytes` +- `request_envelope` +- `signature` + +### Request Envelope + +Minimal required fields: + +- `protocol_version` +- `device_session_id` +- `message_type` +- `timestamp_ms` +- `request_id` +- `payload_hash` + +### Request Signing Input + +The client signs canonical bytes built from: + +- request domain marker, for example `myapp-request-v1` +- `protocol_version` +- `device_session_id` +- `message_type` +- `timestamp_ms` +- `request_id` +- `payload_hash` + +`payload_hash` should be computed from raw `payload_bytes`. + +The goal is to bind the signature to: + +- the concrete device session +- the concrete message type +- the concrete payload +- a fresh request instance + +## Response Structure + +Each server response logically contains: + +- `payload_bytes` +- `response_envelope` +- `signature` + +### Response Envelope + +Minimal required fields: + +- `protocol_version` +- `request_id` +- `timestamp_ms` +- `result_code` +- `payload_hash` + +### Response Signing Input + +The server signs canonical bytes built from: + +- response domain marker, for example `myapp-response-v1` +- `protocol_version` +- `request_id` +- `timestamp_ms` +- `result_code` +- `payload_hash` + +The client verifies the signature using a trusted server public key. + +## Verification Order on Server + +Before processing payload, the server/gateway must: + +1. verify that the transport envelope is present and supported +2. resolve `device_session_id` +3. reject unknown or revoked sessions +4. verify client signature using stored public key +5. verify timestamp freshness window +6. verify anti-replay constraints using `request_id` +7. only then pass payload to business processing + +## Verification Order on Client + +Before accepting response payload, the client must: + +1. verify server signature +2. verify `request_id` matches the corresponding request +3. verify `payload_hash` +4. verify timestamp freshness if applicable +5. only then accept the response payload + +## Anti-Replay Model + +Transport anti-replay uses: + +- `timestamp_ms` +- `request_id` + +The server accepts requests only inside an allowed time window. +Recently seen `request_id` values must be tracked for the corresponding session and rejected on reuse. + +This protects transport freshness. +It does not replace business idempotency. + +## Server Time Offset + +Clients use server time offset instead of trusting local clock directly. + +Expected approach: + +- client establishes authenticated long-polling / push connection +- server provides current server time +- client computes local offset +- subsequent signed requests use adjusted time + +No extra sync request is required if push / long-polling already exists. + +## TLS and MITM Considerations + +### Native Clients notes + +Native clients should use TLS pinning in addition to signed request/response exchange. +Pinning should be based on public key / SPKI rather than leaf certificate whenever possible. + +### Browser / WASM Clients notes + +Real TLS pinning is not available in the browser in the same way as in native clients. +Browser clients still use the signed request/response model, but browser-managed TLS remains the platform limitation. + +## Threat Model Boundaries + +This design protects against: + +- request/response tampering in transit +- replay of previously seen transport messages inside the protected window +- use of unknown or revoked device sessions +- forged server responses without server signing key +- forged client requests without client signing key + +This design does not guarantee that a legitimate user cannot generate their own valid requests from their own client environment. +That is handled by server-side business validation and authorization. + +## Architectural Notes + +- Transport authentication and business authorization are separate concerns. +- Signed transport proves message origin and integrity. +- Business services must still validate command correctness, ownership, permissions, and state transitions. +- Transport `request_id` is not the same as business idempotency key. + +## Recommended Outcome + +The system should treat the secure exchange layer as the mandatory outer contract for all authenticated traffic. +Only after successful transport validation may payload be routed to business logic. diff --git a/client/README.md b/client/README.md index 6514722..a240f26 100644 --- a/client/README.md +++ b/client/README.md @@ -1,22 +1,10 @@ # Client for Galaxy Plus -## Ship Calculator +UI Client is capable of: -```text - Class: [ ] { Create } - Drives: [20.000] x 1.013 [O--] - Weapons: [ 0.000] x [1.000] [==0] -Armament: [ 0 ] -Schields: [ 5.500] @ [1.123] - Cargo: [30.125] @ [1.320] - - Mass: [ 123,45 ] [==0] - Speed: ( 12,456 ) [O--] - Attack: 0 - Defense: 100,0 - -Planet { Name } Production: -[ 100.0 ] MAT per turn produced [O--] supplied -{ N.000 } ship(s) per turn -{ M.000 } turn(s) per ship -``` +- Register a new player and login for an existing player using only e-mail and one-time codes, +- Enlist to a new Game from available onboard Games list, +- Request list of Games in which Player participating, +- Request, store and display particular Game data, +- Use push-like mechanism for receiving asynchronous updates from Server, +- Offline mode when no internet connection is available or user desired to work offline. diff --git a/client/background.go b/client/background.go index 0fbcd5f..67f424e 100644 --- a/client/background.go +++ b/client/background.go @@ -1,6 +1,7 @@ package client import ( + "fmt" "time" gerr "galaxy/error" @@ -9,10 +10,11 @@ import ( var ( checkConnectionInterval = 5 * time.Second checkVersionInterval = time.Hour + statePersistInterval = time.Second ) func (e *client) startBackground() { - if e.fullConnector == nil && e.updater == nil { + if e.conn == nil || e.updater == nil { return } @@ -28,9 +30,11 @@ func (e *client) stopBackground() { func (e *client) backgroundLoop() { checkConnTimer := time.NewTimer(checkConnectionInterval) checkVersionTimer := time.NewTimer(checkVersionInterval) + persistStateTimer := time.NewTimer(statePersistInterval) defer func() { checkConnTimer.Stop() checkVersionTimer.Stop() + persistStateTimer.Stop() }() for { @@ -38,8 +42,8 @@ func (e *client) backgroundLoop() { case <-e.backgroundStop: return case <-checkConnTimer.C: - if e.fullConnector != nil { - e.OnConnection(e.fullConnector.CheckConnection()) + if e.conn != nil { + e.OnConnection(e.conn.CheckConnection()) } checkConnTimer.Reset(checkConnectionInterval) case <-checkVersionTimer.C: @@ -49,15 +53,58 @@ func (e *client) backgroundLoop() { } } checkVersionTimer.Reset(checkVersionInterval) + case <-persistStateTimer.C: + e.ensureStatePersist() + persistStateTimer.Reset(statePersistInterval) } } } +func (e *client) ensureStatePersist() { + param := e.GetParams() + needSaving := false + e.stateMu.Lock() + if e.world != nil { + if param.CameraZoom > 0 && param.CameraZoom != e.state.CameraZoom { + e.state.CameraZoom = param.CameraZoom + needSaving = true + } + if param.CameraXWorldFp != e.state.CameraXFp { + e.state.CameraXFp = param.CameraXWorldFp + needSaving = true + } + if param.CameraYWorldFp != e.state.CameraYFp { + e.state.CameraYFp = param.CameraYWorldFp + needSaving = true + } + } + if e.mapSplitter != nil && e.mapSplitter.Offset != e.state.MapSplitterOffset { + e.state.MapSplitterOffset = e.mapSplitter.Offset + needSaving = true + } + if e.accInfo.Open != e.state.AccordionInfoOpen { + e.state.AccordionInfoOpen = e.accInfo.Open + needSaving = true + } + if e.accCalc.Open != e.state.AccordionCalcOpen { + e.state.AccordionCalcOpen = e.accCalc.Open + needSaving = true + } + if needSaving { + if err := e.s.SaveState(*e.state); err != nil { + e.handlerError(err) + } + } + e.stateMu.Unlock() +} + func (e *client) handlerError(err error) { if err == nil { return } + fmt.Printf("ERROR: %s\n", err) + switch { case gerr.IsConnection(err): e.OnConnectionError(err) diff --git a/client/canvas.go b/client/canvas.go index 4d07841..4b7bb26 100644 --- a/client/canvas.go +++ b/client/canvas.go @@ -11,7 +11,6 @@ import ( type interactiveRaster struct { widget.BaseWidget - edit *client min fyne.Size raster *canvas.Raster onLayout func(fyne.Size) @@ -50,7 +49,6 @@ func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) { func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {} func newInteractiveRaster( - edit *client, raster *canvas.Raster, onLayout func(fyne.Size), onScrolled func(*fyne.ScrollEvent), @@ -60,7 +58,6 @@ func newInteractiveRaster( ) *interactiveRaster { r := &interactiveRaster{ raster: raster, - edit: edit, onLayout: onLayout, onScrolled: onScrolled, onDragged: onDragged, diff --git a/client/client.go b/client/client.go index 8d0ea9f..024a7ee 100644 --- a/client/client.go +++ b/client/client.go @@ -5,15 +5,16 @@ import ( "sync" "galaxy/client/updater" + "galaxy/client/widget/calculator" "galaxy/client/world" "galaxy/connector" mc "galaxy/model/client" - "galaxy/model/report" "galaxy/storage" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) @@ -21,12 +22,22 @@ import ( const version = "1.0.0" type client struct { - s storage.UIStorage - conn connector.UIConnector + s storage.Storage + conn connector.Connector app fyne.App window fyne.Window - loadReportFunc func(uint) + state *mc.State + stateMu sync.RWMutex + + reg *registry + + calculator *calculator.Calculator + mapSplitter *container.Split + accInfo *widget.AccordionItem + accCalc *widget.AccordionItem + + // loadReportFunc func(uint) world *world.World drawer *world.GGDrawer @@ -69,8 +80,6 @@ type client struct { hits []world.Hit - fullStorage storage.Storage - fullConnector connector.Connector updater *updater.Manager backgroundStop chan struct{} backgroundOnce sync.Once @@ -81,32 +90,55 @@ type client struct { onServiceErrFn func(error) } -func NewClient(s storage.UIStorage, conn connector.UIConnector, app fyne.App) (mc.Client, error) { +func NewClient(s storage.Storage, conn connector.Connector, app fyne.App) (mc.Client, error) { e := &client{ - s: s, - conn: conn, - app: app, - window: app.NewWindow("Galaxy Plus"), - world: nil, - wp: &world.RenderParams{ - CameraZoom: 1.0, - Options: &world.RenderOptions{DisableWrapScroll: false}, - }, + s: s, + conn: conn, + app: app, + window: app.NewWindow("Galaxy Plus"), + reg: newRegistry(), lastCanvasScale: 1.0, + world: nil, hits: make([]world.Hit, 5), backgroundStop: make(chan struct{}), } - if fullStorage, ok := s.(storage.Storage); ok { - e.fullStorage = fullStorage - } - if fullConnector, ok := conn.(connector.Connector); ok { - e.fullConnector = fullConnector - } - if e.fullStorage != nil && e.fullConnector != nil { - e.updater = updater.NewManager(e.fullStorage, e.fullConnector) - } + e.calculator = calculator.NewCaclulator(calculator.WithCreateHandler(e.createShipClass)) + e.updater = updater.NewManager(e.s, e.conn) - e.loadReportFunc = e.loadReport + stateExists, err := e.s.StateExists() + if err != nil { + return nil, err + } + if stateExists { + state, err := e.s.LoadState() + if err != nil { + return nil, err + } + e.state = &state + } else { + e.state = &mc.State{ + ClientCurrentVersion: e.Version(), + CameraZoom: 1.0, + MapSplitterOffset: 0.5, + AccordionInfoOpen: false, + AccordionCalcOpen: false, + } + if err := e.s.SaveState(*e.state); err != nil { + return nil, err + } + } + if e.state.CameraZoom <= 0 { + e.state.CameraZoom = 1.0 + } + if e.state.MapSplitterOffset <= 0 { + e.state.MapSplitterOffset = 0.5 + } + e.wp = &world.RenderParams{ + Options: &world.RenderOptions{DisableWrapScroll: false}, + CameraZoom: e.state.CameraZoom, + CameraXWorldFp: e.state.CameraXFp, + CameraYWorldFp: e.state.CameraYFp, + } e.drawer = &world.GGDrawer{DC: nil} @@ -127,40 +159,13 @@ func NewClient(s storage.UIStorage, conn connector.UIConnector, app fyne.App) (m return e, nil } -func (e *client) loadReport(t uint) { - e.conn.FetchReport("GAME_ID", t, func(r report.Report, err error) { - if err != nil { - e.handlerError(err) - } else { - e.setReport(r) - } - }) -} - -func (e *client) setReport(r report.Report) { - w := world.NewWorld(int(r.Width), int(r.Height)) - for i := range r.LocalPlanet { - p := r.LocalPlanet[i] - w.AddCircle(p.X.F(), p.Y.F(), p.Size.F()) - } - for i := range r.UnidentifiedPlanet { - p := r.UnidentifiedPlanet[i] - w.AddPoint(p.X.F(), p.Y.F()) - } - e.loadWorld(w) -} - func (e *client) BuildUI(w fyne.Window) { - mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) - mapCanvas.SetMinSize(fyne.NewSize(640, 480)) + mapCanvasObject := newInteractiveRaster(e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) toolbar := widget.NewToolbar( widget.NewToolbarAction( theme.FolderIcon(), - func() { - e.loadReport(0) - // e.loadWorld(mockWorld()) - }), + func() { e.initReportAsync("GAME_ID", 0) }), widget.NewToolbarSeparator(), widget.NewToolbarAction( theme.NavigateBackIcon(), @@ -170,11 +175,24 @@ func (e *client) BuildUI(w fyne.Window) { func() {}), ) + e.accInfo = widget.NewAccordionItem(lang.L("title.info"), container.NewStack()) + e.accInfo.Open = e.state.AccordionInfoOpen + e.accCalc = widget.NewAccordionItem(lang.L("title.calculator"), e.calculator.CanvasObject) + e.accCalc.Open = e.state.AccordionCalcOpen + + accordion := widget.NewAccordion() + accordion.MultiOpen = true + accordion.Append(e.accCalc) + accordion.Append(e.accInfo) + + e.mapSplitter = container.NewHSplit(mapCanvasObject, container.NewHScroll(accordion)) + e.mapSplitter.SetOffset(e.state.MapSplitterOffset) + tabs := container.NewAppTabs( container.NewTabItemWithIcon( - "Map", + lang.L("title.map"), theme.GridIcon(), - mapCanvas), + e.mapSplitter), container.NewTabItemWithIcon( "Calculator", theme.ComputerIcon(), @@ -182,16 +200,33 @@ func (e *client) BuildUI(w fyne.Window) { ), ) + th := tabs.Theme() + icon := canvas.NewImageFromResource(th.Icon(theme.IconNameInfo)) + + statusLeft := widget.NewTextGridFromString("Status") + statusAd := widget.NewTextGridFromString("") + + statusBar := container.NewBorder( + nil, // top + nil, // bottom + container.NewHBox(statusLeft, widget.NewSeparator()), // left + container.NewHBox(widget.NewSeparator(), icon), // right + statusAd, // center + ) + content := container.NewBorder( - toolbar, // top - nil, // bottom - nil, // left - nil, // right - tabs, // center + toolbar, // top + statusBar, // bottom + nil, // left + nil, // right + tabs, // center ) w.CenterOnScreen() w.SetContent(content) + s := statusBar.Size() + icon.SetMinSize(fyne.NewSize(s.Height, s.Height)) + e.initLatestReport() } func (e *client) loadWorld(w *world.World) { @@ -215,16 +250,18 @@ func (e *client) Run() error { e.window.SetMaster() e.window.Resize(fyne.NewSize(800, 600)) e.window.CenterOnScreen() + e.window.SetOnClosed(e.Shutdown) e.window.ShowAndRun() - e.stopBackground() return nil } func (e *client) Shutdown() { e.stopBackground() + e.ensureStatePersist() e.window.Close() } +// TODO: remove func? func (e *client) Version() string { return version } func (e *client) OnConnection(isGood bool) { diff --git a/client/cmd/loader/main.go b/client/cmd/loader/main.go index 1f6e1bd..a27ceb4 100644 --- a/client/cmd/loader/main.go +++ b/client/cmd/loader/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "galaxy/client" "galaxy/client/appmeta" "galaxy/client/loader" "galaxy/connector/http" @@ -12,6 +13,7 @@ import ( "os/signal" "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/lang" ) func main() { @@ -19,7 +21,7 @@ func main() { defer func() { if err == nil { if r := recover(); r != nil { - err = errors.Join(err, fmt.Errorf("app panics: %v", r)) + err = errors.Join(err, fmt.Errorf("panic: %v", r)) } } if err != nil { @@ -32,6 +34,9 @@ func main() { defer cancel() app := app.NewWithID(appmeta.AppID) + if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil { + return + } s, err := fs.NewFS(app.Storage().RootURI().Path()) if err != nil { return @@ -44,6 +49,5 @@ func main() { if err != nil { return } - err = l.Run(ctx) } diff --git a/client/cmd/ui/main.go b/client/cmd/ui/main.go index 1e83fb6..a3c8bcc 100644 --- a/client/cmd/ui/main.go +++ b/client/cmd/ui/main.go @@ -4,14 +4,15 @@ import ( "context" "errors" "fmt" - "galaxy/client/appmeta" "galaxy/client" + "galaxy/client/appmeta" "galaxy/connector/http" "galaxy/storage/fs" "os" "os/signal" "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/lang" ) func main() { @@ -19,7 +20,7 @@ func main() { defer func() { if err == nil { if r := recover(); r != nil { - err = errors.Join(err, fmt.Errorf("app panics: %v", r)) + err = errors.Join(err, fmt.Errorf("panic: %v", r)) } } if err != nil { @@ -31,6 +32,9 @@ func main() { defer cancel() app := app.NewWithID(appmeta.AppID) + if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil { + return + } s, err := fs.NewFS(app.Storage().RootURI().Path()) if err != nil { return diff --git a/client/embed.go b/client/embed.go new file mode 100644 index 0000000..ba4ddee --- /dev/null +++ b/client/embed.go @@ -0,0 +1,6 @@ +package client + +import "embed" + +//go:embed resource/lang +var Translations embed.FS diff --git a/client/hit.go b/client/hit.go new file mode 100644 index 0000000..93beed0 --- /dev/null +++ b/client/hit.go @@ -0,0 +1,61 @@ +package client + +import ( + "galaxy/client/world" + + "fyne.io/fyne/v2" +) + +var m = func(v int) float64 { return float64(v) / float64(world.SCALE) } + +func (e *client) onTapped(ev *fyne.PointEvent) { + if e.world == nil || ev == nil { + return + } + + xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y) + if !ok { + return + } + + params := e.getLastRenderedParams() + hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx) + if err != nil { + e.handlerError(err) + return + } + + if len(hits) == 0 { + e.calculator.UnloadPlanet() + return + } + + for i := range hits { + e.onHit(hits[i]) + } +} + +func (e *client) onHit(hit world.Hit) { + // var coord string + // if hit.Kind == world.KindLine { + // coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2)) + // } else { + // coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y)) + // } + // fmt.Println("hit:", hit.ID, "Coord:", coord) + switch hit.Kind { + case world.KindPoint: + case world.KindCircle: + e.onHitCircle(hit.ID) + case world.KindLine: + } +} + +func (e *client) onHitCircle(id world.PrimitiveID) { + p, ok := e.reg.localPlanet(id) + if !ok { + return + } + e.calculator.LoadPlanet(p.Name, p.Number, p.FreeIndustry.F(), p.Material.F(), p.Resources.F()) + e.calculator.Refresh() +} diff --git a/client/registry.go b/client/registry.go new file mode 100644 index 0000000..c61b146 --- /dev/null +++ b/client/registry.go @@ -0,0 +1,76 @@ +package client + +import ( + "galaxy/client/world" + "galaxy/model/report" +) + +const ( + entityClassUnknown int = iota - 1 + entityClassLocalPlanet + entityClassOthersPlanet + entityClassFreePlanet + entityClassUnidentifiedPlanet +) + +type registry struct { + report *report.Report + localPlanetIndex map[world.PrimitiveID]int + unidentifiedPlanetIndex map[world.PrimitiveID]int +} + +func newRegistry() *registry { + return ®istry{ + localPlanetIndex: make(map[world.PrimitiveID]int), + unidentifiedPlanetIndex: make(map[world.PrimitiveID]int), + } +} + +func (r *registry) clear(report *report.Report) { + r.report = report + clear(r.localPlanetIndex) + clear(r.unidentifiedPlanetIndex) +} + +func (r *registry) entityClass(id world.PrimitiveID) int { + if r.isLocalPlanet(id) { + return entityClassLocalPlanet + } + if r.isUnidentifiedPlanet(id) { + return entityClassUnidentifiedPlanet + } + return entityClassUnknown +} + +func (r *registry) registerLocalPlanet(id world.PrimitiveID, index int) { + r.localPlanetIndex[id] = index +} + +func (r *registry) isLocalPlanet(id world.PrimitiveID) bool { + _, ok := r.localPlanetIndex[id] + return ok +} + +func (r *registry) localPlanet(id world.PrimitiveID) (*report.LocalPlanet, bool) { + i, ok := r.localPlanetIndex[id] + if !ok { + return nil, false + } + if i > len(r.report.LocalPlanet)-1 { + return nil, false + } + return &r.report.LocalPlanet[i], true +} + +func (r *registry) registerUnidentifiedPlanet(id world.PrimitiveID, index int) { + r.unidentifiedPlanetIndex[id] = index +} + +func (r *registry) isUnidentifiedPlanet(id world.PrimitiveID) bool { + _, ok := r.unidentifiedPlanetIndex[id] + return ok +} + +func (c *client) createShipClass(n string, D float64, A uint, W float64, S float64, C float64) { + +} diff --git a/client/report.go b/client/report.go new file mode 100644 index 0000000..60e6646 --- /dev/null +++ b/client/report.go @@ -0,0 +1,119 @@ +package client + +import ( + "fmt" + "galaxy/client/widget/calculator" + "galaxy/client/world" + mc "galaxy/model/client" + "galaxy/model/report" + "slices" + + "fyne.io/fyne/v2" +) + +func (e *client) initLatestReport() { + e.stateMu.Lock() + if e.state.ActiveGameID != nil { + if stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == *e.state.ActiveGameID }); stateIdx >= 0 { + e.initReportAsync(*e.state.ActiveGameID, e.state.GameState[stateIdx].ActiveTurn) + } + } + e.stateMu.Unlock() +} + +func (e *client) initReportAsync(gid mc.GameID, t uint) { + e.s.ReportExistsAsync(gid, t, func(b bool, err error) { e.reportAtStorageExists(gid, t, b, err) }) +} + +func (e *client) reportAtStorageExists(gid mc.GameID, t uint, exists bool, err error) { + if err != nil { + e.handlerError(err) + return + } + if exists { + e.s.LoadReportAsync(gid, t, func(r report.Report, err error) { e.loadReportHandler(gid, r, err) }) + return + } + e.conn.FetchReport(gid, t, func(r report.Report, err error) { e.fetchReportHandler(gid, r, err) }) +} + +func (e *client) fetchReportHandler(gid mc.GameID, r report.Report, err error) { + if err != nil { + e.handlerError(err) + return + } + + e.s.SaveReportAsync(gid, r.Turn, r, func(err error) { e.loadReportHandler(gid, r, err) }) +} + +func (e *client) loadReportHandler(gid mc.GameID, r report.Report, err error) { + if err != nil { + e.handlerError(err) + return + } + + e.stateMu.Lock() + needSaveState := false + stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == gid }) + if stateIdx < 0 { + e.state.GameState = append(e.state.GameState, mc.GameState{ID: gid, LastTurn: r.Turn, ActiveTurn: r.Turn}) + stateIdx = len(e.state.GameState) - 1 + needSaveState = true + } + if e.state.ActiveGameID == nil { + e.state.ActiveGameID = new(gid) + needSaveState = true + } + if e.state.GameState[stateIdx].LastTurn < r.Turn { + e.state.GameState[stateIdx].LastTurn = r.Turn + e.state.GameState[stateIdx].ActiveTurn = r.Turn + needSaveState = true + } + if needSaveState { + if err := e.s.SaveState(*e.state); err != nil { + e.handlerError(err) + return + } + } + e.stateMu.Unlock() + + e.setReport(r) +} + +func (e *client) setReport(r report.Report) { + w := world.NewWorld(int(r.Width), int(r.Height)) + e.reg.clear(&r) + for i := range r.LocalPlanet { + p := r.LocalPlanet[i] + id, err := w.AddCircle(p.X.F(), p.Y.F(), p.Size.F(), world.CircleWithClass(world.CircleClassLocalPlanet)) + if err != nil { + e.handlerError(err) + return + } + e.reg.registerLocalPlanet(id, i) + } + for i := range r.UnidentifiedPlanet { + p := r.UnidentifiedPlanet[i] + id, err := w.AddPoint(p.X.F(), p.Y.F(), world.PointWithClass(world.PointClassTrackIncoming)) + if err != nil { + e.handlerError(err) + return + } + e.reg.registerUnidentifiedPlanet(id, i) + } + e.loadWorld(w) + + selfIdx := slices.IndexFunc(r.Player, func(p report.Player) bool { return p.Name == r.Race }) + if selfIdx >= 0 { + fyne.Do(func() { + e.calculator.Init( + calculator.WithPlayerDrives(r.Player[selfIdx].Drive.F()), + calculator.WithPlayerWeapons(r.Player[selfIdx].Weapons.F()), + calculator.WithPlayerShields(r.Player[selfIdx].Shields.F()), + calculator.WithPlayerCargo(r.Player[selfIdx].Cargo.F()), + ) + }) + } else { + e.OnServiceError(fmt.Errorf("race %q not found at report players list", r.Race)) + } +} diff --git a/client/resource/lang/en.json b/client/resource/lang/en.json new file mode 100644 index 0000000..5dab7ee --- /dev/null +++ b/client/resource/lang/en.json @@ -0,0 +1,30 @@ +{ + "title": { + "map": "Map", + "calculator": "Ship Calculator", + "info": "Info" + }, + "planet": { + "title": "Planet #{{.Number}} '{{.Name}}' production fot this ship:", + "mat": "Materials", + "prod.mass": "Prod. Mass", + "prod.ships": "Ships" + }, + "tech": { + "d": "Drive", + "w": "Weapons", + "s": "Shields", + "c": "Cargo" + }, + "ship": { + "action.create": "Create", + "mass": "Mass", + "speed": "Speed", + "attack": "Attack", + "defense": "Defense", + "load": "Load" + }, + "label": { + "max": "Max." + } +} \ No newline at end of file diff --git a/client/ui.go b/client/ui.go index d432b9d..8e76a53 100644 --- a/client/ui.go +++ b/client/ui.go @@ -1,7 +1,6 @@ package client import ( - "fmt" "image" "math" @@ -256,36 +255,6 @@ func (e *client) onDradEnd() { e.pan.DragEnd() } -func (e *client) onTapped(ev *fyne.PointEvent) { - if e.world == nil || ev == nil { - return - } - - xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y) - if !ok { - return - } - - params := e.getLastRenderedParams() - hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx) - if err != nil { - // In UI you probably don't want panic; keep your existing handling. - panic(err) - } - - m := func(v int) float64 { return float64(v) / float64(world.SCALE) } - - for _, hit := range hits { - var coord string - if hit.Kind == world.KindLine { - coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2)) - } else { - coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y)) - } - fmt.Println("hit:", hit.ID, "Coord:", coord) - } -} - func (e *client) onScrolled(s *fyne.ScrollEvent) { if e.world == nil || s == nil { return diff --git a/client/widget/calculator/calculator.go b/client/widget/calculator/calculator.go new file mode 100644 index 0000000..e9922c7 --- /dev/null +++ b/client/widget/calculator/calculator.go @@ -0,0 +1,629 @@ +package calculator + +import ( + "errors" + "galaxy/calc" + "galaxy/client/widget/numeric" + "galaxy/util" + "slices" + "strconv" + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/lang" + "fyne.io/fyne/v2/widget" +) + +type CalculatorOpt func(*Calculator) +type ShipClass struct { + Name string + Drive float64 + Armament uint + Weapons float64 + Shields float64 + Cargo float64 +} +type ShipClassFn func(string, float64, uint, float64, float64, float64) + +type Calculator struct { + CanvasObject fyne.CanvasObject + + playerDrivesTech float64 + playerWeaponsTech float64 + playerShieldsTech float64 + playerCargoTech float64 + + shipDriveEntry *numeric.FloatEntry + shipWeaponsEntry *numeric.FloatEntry + shipArmamentEntry *numeric.IntEntry + shipShieldsEntry *numeric.FloatEntry + shipCargoEntry *numeric.FloatEntry + + playerDrivesTechEntry *numeric.FloatEntry + playerWeaponsTechEntry *numeric.FloatEntry + playerShieldsTechEntry *numeric.FloatEntry + playerCargoTechEntry *numeric.FloatEntry + + drivesTechOverride *widget.Check + weaponsTechOverride *widget.Check + shieldsTechOverride *widget.Check + cargoTechOverride *widget.Check + + massEntry *numeric.FloatEntry + speedEntry *numeric.FloatEntry + attackEntry *numeric.FloatEntry + defenseEntry *numeric.FloatEntry + cargoLoadEntry *numeric.FloatEntry + planetMatEntry *numeric.FloatEntry + + massOverride *widget.Check + speedOverride *widget.Check + attackOverride *widget.Check + defenseOverride *widget.Check + cargoLoadMaximize *widget.Check + planetMatOverride *widget.Check + + planetLabel *widget.Label + planetMassProdLabel *widget.Label + planetShipsProdLabel *widget.Label + planetContainer fyne.CanvasObject + planetProdContainer fyne.CanvasObject + + shipSelector *widget.SelectEntry + shipCreateButton *widget.Button + + onCreateHandler ShipClassFn + loader ShipClassFn + knownClasses []ShipClass + + validateMu sync.RWMutex + + l, mat, res float64 + + Valid bool +} + +func WithPlayerDrives(v float64) CalculatorOpt { + return func(c *Calculator) { c.playerDrivesTech = v } +} + +func WithPlayerWeapons(v float64) CalculatorOpt { + return func(c *Calculator) { c.playerWeaponsTech = v } +} + +func WithPlayerShields(v float64) CalculatorOpt { + return func(c *Calculator) { c.playerShieldsTech = v } +} + +func WithPlayerCargo(v float64) CalculatorOpt { + return func(c *Calculator) { c.playerCargoTech = v } +} + +func WithCreateHandler(f ShipClassFn) CalculatorOpt { + return func(c *Calculator) { c.onCreateHandler = f } +} + +func NewCaclulator(opts ...CalculatorOpt) *Calculator { + c := &Calculator{} + + c.shipCreateButton = widget.NewButton(lang.L("ship.action.create"), c.onCreateShipClassButton) + c.shipCreateButton.Disable() + c.loader = c.LoadShipClass + + c.planetMatEntry = numeric.NewFloatEntry(10, c.onPlanetMatChange) + c.planetMatOverride = widget.NewCheck("", c.overridePlanetMat) + c.planetMatOverride.Disable() + c.planetLabel = widget.NewLabel("") + c.planetMassProdLabel = bareLabel("") + c.planetShipsProdLabel = bareLabel("") + c.planetProdContainer = container.NewHBox( + label(lang.L("planet.prod.mass")+":"), + fixedLabel(c.planetMassProdLabel, 80), + label(lang.L("planet.prod.ships")+":"), + fixedLabel(c.planetShipsProdLabel, 80), + ) + c.planetProdContainer.Hide() + + c.planetContainer = container.NewVBox( + widget.NewSeparator(), + container.NewHBox(c.planetLabel), + rowForItem(lang.L("planet.mat")+":", floatEntry(c.planetMatEntry, 100), c.planetMatOverride), + c.planetProdContainer, + ) + c.planetContainer.Hide() + + c.shipSelector = widget.NewSelectEntry(nil) + c.shipSelector.OnChanged = c.onShipSelectorChange + + c.shipDriveEntry = numeric.NewFloatEntry(7, c.onShipDriveChange) + c.shipWeaponsEntry = numeric.NewFloatEntry(7, c.onShipWeaponsChange) + c.shipArmamentEntry = numeric.NewIntEntry(7, c.onShipArmamentChange) + c.shipShieldsEntry = numeric.NewFloatEntry(7, c.onShipShieldsChange) + c.shipCargoEntry = numeric.NewFloatEntry(7, c.onShipCargoChange) + + c.playerDrivesTechEntry = numeric.NewFloatEntry(7, c.onDrivesTechChange) + c.playerWeaponsTechEntry = numeric.NewFloatEntry(7, c.onWeaponsTechChange) + c.playerShieldsTechEntry = numeric.NewFloatEntry(7, c.onShieldsTechChange) + c.playerCargoTechEntry = numeric.NewFloatEntry(7, c.onCargoTechChange) + + c.massEntry = numeric.NewFloatEntry(7, c.onMassChange) + c.speedEntry = numeric.NewFloatEntry(7, c.onSpeedChange) + c.attackEntry = numeric.NewFloatEntry(7, c.onAttackChange) + c.defenseEntry = numeric.NewFloatEntry(7, c.onDefenseChange) + c.cargoLoadEntry = numeric.NewFloatEntry(7, c.onCargoLoadChange) + + c.drivesTechOverride = widget.NewCheck("", c.overrideDrivesTech) + c.drivesTechOverride.Disable() + c.weaponsTechOverride = widget.NewCheck("", c.overrideWeaponsTech) + c.weaponsTechOverride.Disable() + c.shieldsTechOverride = widget.NewCheck("", c.overrideShieldsTech) + c.shieldsTechOverride.Disable() + c.cargoTechOverride = widget.NewCheck("", c.overrideCargoTech) + c.cargoTechOverride.Disable() + + c.massOverride = widget.NewCheck("", c.overrideMass) + c.massOverride.Disable() + c.speedOverride = widget.NewCheck("", c.overrideSpeed) + c.speedOverride.Disable() + c.attackOverride = widget.NewCheck("", c.overrideAttack) + c.attackOverride.Disable() + c.defenseOverride = widget.NewCheck("", c.overrideDefense) + c.defenseOverride.Disable() + c.cargoLoadMaximize = widget.NewCheck(lang.L("label.max"), c.maximizeCargoLoad) + c.cargoLoadMaximize.SetChecked(true) + + createShip := container.NewBorder( + nil, // top + nil, // bottom + nil, // left + c.shipCreateButton, // right + c.shipSelector, // center + ) + + c.CanvasObject = container.NewVBox( + container.NewPadded(createShip), + widget.NewSeparator(), + rowForTech(lang.L("tech.d")+":", + c.shipDriveEntry, floatEntry(c.playerDrivesTechEntry, 80), c.drivesTechOverride), + rowForWeapons(lang.L("tech.w")+":", + c.shipArmamentEntry, c.shipWeaponsEntry, floatEntry(c.playerWeaponsTechEntry, 80), c.weaponsTechOverride), + rowForTech(lang.L("tech.s")+":", + c.shipShieldsEntry, floatEntry(c.playerShieldsTechEntry, 80), c.shieldsTechOverride), + rowForTech(lang.L("tech.c")+":", + c.shipCargoEntry, floatEntry(c.playerCargoTechEntry, 80), c.cargoTechOverride), + widget.NewSeparator(), + rowForItem(lang.L("ship.load")+":", + floatEntry(c.cargoLoadEntry, 80), c.cargoLoadMaximize), + rowForItem(lang.L("ship.mass")+":", + floatEntry(c.massEntry, 80), c.massOverride), + rowForItem(lang.L("ship.speed")+":", + floatEntry(c.speedEntry, 80), c.speedOverride), + rowForItem(lang.L("ship.attack")+":", + floatEntry(c.attackEntry, 80), c.attackOverride), + rowForItem(lang.L("ship.defense")+":", + floatEntry(c.defenseEntry, 80), c.defenseOverride), + c.planetContainer, + ) + + c.Init(opts...) + + return c +} + +func (c *Calculator) Init(opts ...CalculatorOpt) { + for i := range opts { + opts[i](c) + } + + c.playerDrivesTechEntry.SetOrigin(c.playerDrivesTech) + c.playerWeaponsTechEntry.SetOrigin(c.playerWeaponsTech) + c.playerShieldsTechEntry.SetOrigin(c.playerShieldsTech) + c.playerCargoTechEntry.SetOrigin(c.playerCargoTech) + + c.CanvasObject.Show() +} + +func (c *Calculator) Refresh() { + c.validate() + c.CanvasObject.Refresh() +} + +func (c *Calculator) RegisterClasses(shipClass ...ShipClass) { + c.knownClasses = shipClass + names := make([]string, len(c.knownClasses)) + for i := range c.knownClasses { + names[i] = c.knownClasses[i].Name + } + slices.Sort(names) + c.shipSelector = widget.NewSelectEntry(names) + c.shipSelector.OnChanged = c.onShipSelectorChange +} + +func (c *Calculator) onCreateShipClassButton() { + if c.onCreateHandler == nil || !c.Valid { + return + } +} + +func (c *Calculator) validate() { + fyne.Do(func() { + c.validateMu.Lock() + err := c.validateEntries() + c.Valid = err == nil + if err != nil { + } else { + } + c.shipClassNameValidate() + c.validateMu.Unlock() + }) +} + +func (c *Calculator) validateEntries() (err error) { + defer func() { + if err != nil { + c.cargoLoadEntry.Clear() + if !c.massOverride.Checked { + c.massEntry.Clear() + } + if !c.speedOverride.Checked { + c.speedEntry.Clear() + } + if !c.attackOverride.Checked { + c.attackEntry.Clear() + } + if !c.defenseOverride.Checked { + c.defenseEntry.Clear() + } + // c.planetProdContainer.Hide() + } + }() + drive, ok := c.shipDriveEntry.Value() + if !ok { + err = errors.New("Parameter Drive is not valid") + return + } + driveTech, ok := c.playerDrivesTechEntry.Value() + if !ok { + err = errors.New("Drive tech level is not valid") + return + } + armament, ok := c.shipArmamentEntry.Value() + if !ok { + err = errors.New("Parameter Armament is not valid") + return + } + weapons, ok := c.shipWeaponsEntry.Value() + if !ok { + err = errors.New("Parameter Weapons is not valid") + return + } + weaponsTech, ok := c.playerWeaponsTechEntry.Value() + if !ok { + err = errors.New("Weapons tech level is not valid") + return + } + shields, ok := c.shipShieldsEntry.Value() + if !ok { + err = errors.New("Parameter Shields is not valid") + return + } + shieldsTech, ok := c.playerShieldsTechEntry.Value() + if !ok { + err = errors.New("Shields tech level is not valid") + return + } + cargo, ok := c.shipCargoEntry.Value() + if !ok { + err = errors.New("Parameter Cargo is not valid") + return + } + cargoTech, ok := c.playerCargoTechEntry.Value() + if !ok { + err = errors.New("Cargo tech level is not valid") + return + } + + err = calc.ValidateShipTypeValues(drive, armament, weapons, shields, cargo) + if err != nil { + return + } + + var cargoLoad float64 + if c.cargoLoadMaximize.Checked { + cargoLoad = calc.CargoCapacity(cargo, cargoTech) + c.cargoLoadEntry.SetOrigin(cargoLoad) + } else if cargoLoad, ok = c.cargoLoadEntry.Value(); !ok { + err = errors.New("Cargo load value is not valid") + return + } + + emptyMass, ok := calc.EmptyMass(drive, weapons, uint(armament), shields, cargo) + if !ok { + err = errors.New("Unable to calculate empty mass (check armament and weapons)") + return + } + fullMass := calc.FullMass(emptyMass, cargoLoad) + speed := calc.Speed(calc.DriveEffective(drive, driveTech), fullMass) + effectiveAttack := calc.EffectiveAttack(weapons, weaponsTech) + effectiveDefense := calc.EffectiveDefence(shields, shieldsTech, fullMass) + + c.massEntry.SetOrigin(emptyMass) + c.speedEntry.SetOrigin(speed) + c.attackEntry.SetOrigin(effectiveAttack) + c.defenseEntry.SetOrigin(effectiveDefense) + + planetMat, ok := c.planetMatEntry.Value() + if !ok { + // c.planetProdContainer.Hide() + } else { + massProd := calc.PlanetProduceShipMass(c.l, planetMat, c.res) + c.planetMassProdLabel.SetText(strconv.FormatFloat(util.Fixed3(massProd), 'f', -1, 64)) + ships := 0. + if emptyMass > 0 { + ships = massProd / emptyMass + } + c.planetShipsProdLabel.SetText(strconv.FormatFloat(util.Fixed3(ships), 'f', -1, 64)) + c.planetProdContainer.Show() + } + + return +} + +func (c *Calculator) onOriginInputChange(cb *widget.Check, e *numeric.FloatEntry) { + if e == nil { + return + } + if cb != nil { + cb.Checked = e.Overriden() + if !cb.Checked { + cb.Disable() + } else { + cb.Enable() + } + } + c.onFloatEntryChange(e) +} + +func (c *Calculator) onFloatEntryChange(e *numeric.FloatEntry) { + if e == nil { + return + } + e.Validate() + c.validate() +} + +func (c *Calculator) onIntEntryChange(e *numeric.IntEntry) { + if e == nil { + return + } + e.Validate() + c.validate() +} + +func (c *Calculator) overrideChecked(cb *widget.Check, e *numeric.FloatEntry) { + if cb == nil || e == nil { + return + } + if !cb.Checked { + e.Reset() + cb.Disable() + } +} + +func (c *Calculator) onShipDriveChange(string) { + c.onFloatEntryChange(c.shipDriveEntry) +} + +func (c *Calculator) onShipArmamentChange(string) { + defer c.onIntEntryChange(c.shipArmamentEntry) + if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid { + return + } else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid { + return + } else if armament > 0 && weapons == 0 { + c.shipWeaponsEntry.SetOrigin(1.0) + } else if armament == 0 && weapons > 0 { + c.shipWeaponsEntry.SetOrigin(0.0) + } +} + +func (c *Calculator) onShipWeaponsChange(string) { + defer c.onFloatEntryChange(c.shipWeaponsEntry) + if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid { + return + } else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid { + return + } else if weapons > 0 && armament == 0 { + c.shipArmamentEntry.SetOrigin(1) + } else if weapons == 0 && armament > 0 { + c.shipArmamentEntry.SetOrigin(0) + } +} + +func (c *Calculator) onShipShieldsChange(string) { + c.onFloatEntryChange(c.shipShieldsEntry) +} + +func (c *Calculator) onShipCargoChange(string) { + c.onFloatEntryChange(c.shipCargoEntry) +} + +func (c *Calculator) onDrivesTechChange(string) { + c.onOriginInputChange(c.drivesTechOverride, c.playerDrivesTechEntry) +} + +func (c *Calculator) overrideDrivesTech(bool) { + c.overrideChecked(c.drivesTechOverride, c.playerDrivesTechEntry) +} + +func (c *Calculator) onWeaponsTechChange(string) { + c.onOriginInputChange(c.weaponsTechOverride, c.playerWeaponsTechEntry) +} + +func (c *Calculator) overrideWeaponsTech(bool) { + c.overrideChecked(c.weaponsTechOverride, c.playerWeaponsTechEntry) +} + +func (c *Calculator) onShieldsTechChange(string) { + c.onOriginInputChange(c.shieldsTechOverride, c.playerShieldsTechEntry) +} + +func (c *Calculator) overrideShieldsTech(bool) { + c.overrideChecked(c.shieldsTechOverride, c.playerShieldsTechEntry) +} + +func (c *Calculator) onCargoTechChange(string) { + c.onOriginInputChange(c.cargoTechOverride, c.playerCargoTechEntry) +} + +func (c *Calculator) overrideCargoTech(bool) { + c.overrideChecked(c.cargoTechOverride, c.playerCargoTechEntry) +} + +func (c *Calculator) onCargoLoadChange(string) { + c.onFloatEntryChange(c.cargoLoadEntry) +} + +func (c *Calculator) onMassChange(string) { + c.onOriginInputChange(c.massOverride, c.massEntry) +} + +func (c *Calculator) overrideMass(bool) { + c.overrideChecked(c.massOverride, c.massEntry) +} + +func (c *Calculator) onSpeedChange(string) { + c.onOriginInputChange(c.speedOverride, c.speedEntry) +} + +func (c *Calculator) overrideSpeed(bool) { + c.overrideChecked(c.speedOverride, c.speedEntry) +} + +func (c *Calculator) onAttackChange(string) { + c.onOriginInputChange(c.attackOverride, c.attackEntry) +} + +func (c *Calculator) overrideAttack(bool) { + c.overrideChecked(c.attackOverride, c.attackEntry) +} + +func (c *Calculator) onDefenseChange(string) { + c.onOriginInputChange(c.defenseOverride, c.defenseEntry) +} + +func (c *Calculator) overrideDefense(bool) { + c.overrideChecked(c.defenseOverride, c.defenseEntry) +} + +func (c *Calculator) maximizeCargoLoad(bool) { + c.validate() +} + +func (c *Calculator) onPlanetMatChange(string) { + c.onOriginInputChange(c.planetMatOverride, c.planetMatEntry) +} + +func (c *Calculator) overridePlanetMat(bool) { + c.overrideChecked(c.planetMatOverride, c.planetMatEntry) +} + +func (c *Calculator) onShipSelectorChange(v string) { + i, ok := c.shipClassNameValidate() + if i < 0 || !ok || c.loader == nil { + return + } + c.loader( + c.knownClasses[i].Name, + c.knownClasses[i].Drive, + c.knownClasses[i].Armament, + c.knownClasses[i].Weapons, + c.knownClasses[i].Shields, + c.knownClasses[i].Cargo, + ) +} + +func (c *Calculator) shipClassNameValidate() (int, bool) { + var canCreateShip bool + defer func() { + if canCreateShip && c.Valid { + c.shipCreateButton.Enable() + } else { + c.shipCreateButton.Disable() + } + }() + name, canCreateShip := util.ValidateTypeName(c.shipSelector.Text) + if canCreateShip { + c.shipSelector.Text = name + } + i := slices.IndexFunc(c.knownClasses, func(v ShipClass) bool { return v.Name == name }) + canCreateShip = canCreateShip && i < 0 + return i, canCreateShip +} + +func (c *Calculator) LoadShipClass(n string, D float64, A uint, W float64, S float64, C float64) { + c.shipDriveEntry.SetOrigin(D) + c.shipArmamentEntry.SetOrigin(int(A)) + c.shipWeaponsEntry.SetOrigin(W) + c.shipShieldsEntry.SetOrigin(S) + c.shipCargoEntry.SetOrigin(C) +} + +func rowForItem(l string, entry, override fyne.CanvasObject) fyne.CanvasObject { + i := []fyne.CanvasObject{label(l), entry} + if override != nil { + i = append(i, override) + } + return container.NewHBox(i...) +} + +func rowForTech(l string, shipEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject { + return container.NewHBox( + label(l), + floatEntry(shipEntry, 115), + widget.NewLabel("@"), + techEntry, + btn, + ) +} + +func rowForWeapons(l string, armamentEntry, weaponsEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject { + return container.NewHBox( + label(l), + intEntry(armamentEntry, 35), + floatEntry(weaponsEntry, 75), + widget.NewLabel("@"), + techEntry, + btn, + ) +} + +func label(l string) fyne.CanvasObject { + return fixedLabel(bareLabel(l), 110) +} + +func fixedLabel(w *widget.Label, width float32) fyne.CanvasObject { + s := container.NewHScroll(w) + s.SetMinSize(fyne.NewSize(width, 1)) + return s +} + +func bareLabel(l string) *widget.Label { + w := widget.NewLabelWithStyle(l, fyne.TextAlignTrailing, fyne.TextStyle{Monospace: true, Symbol: false}) + w.Selectable = false + w.Truncation = fyne.TextTruncateOff + return w +} + +func intEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject { + s := container.NewHScroll(content) + s.SetMinSize(fyne.NewSize(width, 1)) + return s +} + +func floatEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject { + s := container.NewHScroll(content) + s.SetMinSize(fyne.NewSize(width, 1)) + return s +} diff --git a/client/widget/calculator/planet.go b/client/widget/calculator/planet.go new file mode 100644 index 0000000..368bd16 --- /dev/null +++ b/client/widget/calculator/planet.go @@ -0,0 +1,16 @@ +package calculator + +import ( + "fyne.io/fyne/v2/lang" +) + +func (c *Calculator) UnloadPlanet() { + c.planetContainer.Hide() +} + +func (c *Calculator) LoadPlanet(name string, number uint, L, Mat, Res float64) { + c.l, c.mat, c.res = L, Mat, Res + c.planetLabel.SetText(lang.L("planet.title", map[string]any{"Number": number, "Name": name})) + c.planetMatEntry.SetOrigin(Mat) + c.planetContainer.Show() +} diff --git a/client/widget/numeric/numeric.go b/client/widget/numeric/numeric.go index 00ef5f7..bd51b5f 100644 --- a/client/widget/numeric/numeric.go +++ b/client/widget/numeric/numeric.go @@ -1,30 +1,82 @@ package numeric import ( + "galaxy/client/widget/validator" + "galaxy/util" "strconv" + "strings" + "unicode/utf8" "fyne.io/fyne/v2" "fyne.io/fyne/v2/driver/mobile" "fyne.io/fyne/v2/widget" ) -type numericalEntry struct { +type FloatEntry struct { widget.Entry + origin float64 + MaxValue float64 + maxSize uint + validator fyne.StringValidator + Valid bool } -func NewNumericalEntry() *numericalEntry { - entry := &numericalEntry{} - entry.ExtendBaseWidget(entry) - return entry +type IntEntry struct { + widget.Entry + origin uint + MaxValue uint + maxSize uint + validator fyne.StringValidator + Valid bool } -func (e *numericalEntry) TypedRune(r rune) { - if (r >= '0' && r <= '9') || r == '.' || r == ',' { - e.Entry.TypedRune(r) +func NewFloatEntry(maxSize uint, onChanged func(string)) *FloatEntry { + e := &FloatEntry{maxSize: maxSize, validator: validator.FloatEntryValidator} + e.ExtendBaseWidget(e) + e.Entry.Scroll = fyne.ScrollNone + e.Entry.TextStyle = fyne.TextStyle{Monospace: true} + // e.Validator = validator.FloatEntryValidator + // e.AlwaysShowValidationError = true + e.Entry.ActionItem = nil + e.SetOrigin(0) + e.Validate() + e.Entry.OnChanged = onChanged + return e +} + +func NewIntEntry(maxSize uint, onChanged func(string)) *IntEntry { + e := &IntEntry{maxSize: maxSize, validator: validator.IntEntryValidator} + e.ExtendBaseWidget(e) + e.Entry.Scroll = fyne.ScrollNone + e.Entry.TextStyle = fyne.TextStyle{Monospace: true} + // e.Validator = validator.IntEntryValidator + // e.AlwaysShowValidationError = true + e.Entry.ActionItem = nil + e.SetOrigin(0) + e.Validate() + e.Entry.OnChanged = onChanged + return e +} + +func (e *FloatEntry) CreateRenderer() fyne.WidgetRenderer { + r := e.Entry.CreateRenderer() + return r +} + +func (e *FloatEntry) TypedRune(r rune) { + if !((r >= '0' && r <= '9') || r == '.') { + return } + if !lengthBelowLimit(e.Entry.Text, e.maxSize) && e.Entry.SelectedText() == "" { + return + } + if r == '.' && strings.Contains(e.Entry.Text, ".") { + return + } + e.Entry.TypedRune(r) } -func (e *numericalEntry) TypedShortcut(shortcut fyne.Shortcut) { +func (e *FloatEntry) TypedShortcut(shortcut fyne.Shortcut) { paste, ok := shortcut.(*fyne.ShortcutPaste) if !ok { e.Entry.TypedShortcut(shortcut) @@ -37,6 +89,129 @@ func (e *numericalEntry) TypedShortcut(shortcut fyne.Shortcut) { } } -func (e *numericalEntry) Keyboard() mobile.KeyboardType { +func (e *FloatEntry) Keyboard() mobile.KeyboardType { return mobile.NumberKeyboard } + +func (e *FloatEntry) SetOrigin(v float64) { + if v < 0 { + return + } + e.origin = v + e.Reset() +} + +func (e *FloatEntry) Reset() { + e.SetValue(e.origin) +} + +func (e *FloatEntry) Clear() { + onChanged := e.Entry.OnChanged + e.Entry.OnChanged = nil + e.Entry.SetText("") + e.Entry.OnChanged = onChanged +} + +func (e *FloatEntry) SetValue(v float64) { + if v < 0 { + return + } + + e.Entry.SetText(strconv.FormatFloat(util.Fixed3(v), 'f', -1, 64)) +} + +func (e *FloatEntry) Value() (float64, bool) { + if v, err := validator.ParseFloat(e.Entry.Text); err != nil { + return 0, false + } else { + return v, true + } +} + +func (e *FloatEntry) Overriden() bool { + if v, ok := e.Value(); !ok { + return false + } else { + return util.Fixed3(v) != util.Fixed3(e.origin) + } +} + +func (e *FloatEntry) Validate() { + if e.validator == nil { + return + } + err := e.validator(e.Entry.Text) + e.Valid = err == nil +} + +func (e *IntEntry) TypedRune(r rune) { + if r >= '0' && r <= '9' { + if lengthBelowLimit(e.Entry.Text, e.maxSize) || e.Entry.SelectedText() != "" { + e.Entry.TypedRune(r) + } + } +} + +func (e *IntEntry) TypedShortcut(shortcut fyne.Shortcut) { + paste, ok := shortcut.(*fyne.ShortcutPaste) + if !ok { + e.Entry.TypedShortcut(shortcut) + return + } + + content := paste.Clipboard.Content() + if _, err := strconv.ParseInt(content, 10, 64); err == nil { + e.Entry.TypedShortcut(shortcut) + } +} + +func (e *IntEntry) Keyboard() mobile.KeyboardType { + return mobile.NumberKeyboard +} + +func (e *IntEntry) SetOrigin(v int) { + if v < 0 { + return + } + e.origin = uint(v) + e.Reset() +} + +func (e *IntEntry) Reset() { + e.SetValue(int(e.origin)) +} + +func (e *IntEntry) SetValue(v int) { + if v < 0 { + return + } + e.Entry.SetText(strconv.Itoa(v)) +} + +func (e *IntEntry) Value() (int, bool) { + if v, err := validator.ParseInt(e.Entry.Text); err != nil { + return 0, false + } else { + return v, true + } +} + +func (e *IntEntry) Overriden() bool { + if v, ok := e.Value(); !ok { + return false + } else { + return v != int(e.origin) + } +} + +func (e *IntEntry) Validate() { + if e.validator == nil { + return + } + err := e.validator(e.Entry.Text) + e.Valid = err == nil +} + +func lengthBelowLimit(s string, max uint) bool { + return utf8.RuneCountInString(s) < int(max) +} diff --git a/client/widget/validator/validator.go b/client/widget/validator/validator.go index 79a802e..92eeaa5 100644 --- a/client/widget/validator/validator.go +++ b/client/widget/validator/validator.go @@ -2,11 +2,26 @@ package validator import ( "errors" + "fmt" "strconv" "fyne.io/fyne/v2" ) +type floatValidator func(float64) error + +var ( + FloatEntryValidator = numericEntryValidator( + nonNegativeValidator, + minOrZeroValueValidator(1.), + ) + IntEntryValidator = numericEntryValidator( + intValidator, + nonNegativeValidator, + minOrZeroValueValidator(1.), + ) +) + func NewStackValidator(first fyne.StringValidator, rest ...fyne.StringValidator) fyne.StringValidator { if first == nil { panic("first validator cannot be nil") @@ -43,6 +58,44 @@ func NewMutualValidator(other func() float64, valid func(float64) bool) fyne.Str } } +func numericEntryValidator(other ...floatValidator) fyne.StringValidator { + return func(s string) error { + v, err := ParseFloat(s) + if err != nil { + return errors.New("not a float value") + } + for i := range other { + if err := other[i](v); err != nil { + return err + } + } + return nil + } +} + +func nonNegativeValidator(v float64) error { + if v < 0 { + return errors.New("value must be greater of equal to zero") + } + return nil +} + +func intValidator(v float64) error { + if float64(int(v)) != v { + return errors.New("value must be an integer") + } + return nil +} + +func minOrZeroValueValidator(min float64) floatValidator { + return func(f float64) error { + if f > 0 && f < min { + return fmt.Errorf("value must be zero or >= %f", min) + } + return nil + } +} + func FloatValueValidator(s string) error { if _, err := ParseFloat(s); err != nil { return errors.New("not a float value") @@ -50,6 +103,21 @@ func FloatValueValidator(s string) error { return nil } +func IntValueValidator(s string) error { + if _, err := ParseInt(s); err != nil { + return errors.New("not an integer value") + } + return nil +} + func ParseFloat(s string) (float64, error) { return strconv.ParseFloat(s, 64) } + +func ParseInt(s string) (int, error) { + if v, err := strconv.ParseInt(s, 10, 64); err != nil { + return 0, err + } else { + return int(v), nil + } +} diff --git a/client/world/style.go b/client/world/style.go index 7e9d754..f09c169 100644 --- a/client/world/style.go +++ b/client/world/style.go @@ -415,6 +415,13 @@ func (LightTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) { PointRadiusPx: new(3.5), }, true + case PointClassUnidentifiedPlanet: + // soft orange + return StyleOverride{ + FillColor: cRGBA(192, 192, 192, 255), + PointRadiusPx: new(2.5), + }, true + default: return StyleOverride{}, false } @@ -457,15 +464,7 @@ func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) case CircleClassDefault: return StyleOverride{}, false - case CircleClassHome: - // teal-ish, a bit stronger stroke - return StyleOverride{ - FillColor: cRGBA(32, 161, 145, 50), - StrokeColor: cRGBA(32, 161, 145, 210), - StrokeWidthPx: new(2.5), - }, true - - case CircleClassAcquired: + case CircleClassLocalPlanet: // blue return StyleOverride{ FillColor: cRGBA(70, 108, 196, 45), @@ -473,7 +472,7 @@ func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) StrokeWidthPx: new(2.2), }, true - case CircleClassOccupied: + case CircleClassOthersPlanet: // orange return StyleOverride{ FillColor: cRGBA(222, 142, 70, 50), @@ -481,7 +480,7 @@ func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) StrokeWidthPx: new(2.2), }, true - case CircleClassFree: + case CircleClassFreePlanet: // green return StyleOverride{ FillColor: cRGBA(76, 171, 107, 45), @@ -574,6 +573,12 @@ func (*DarkTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) { PointRadiusPx: new(3.5), }, true + case PointClassUnidentifiedPlanet: + return StyleOverride{ + FillColor: cRGBA(192, 192, 192, 255), + PointRadiusPx: new(2.5), + }, true + default: return StyleOverride{}, false } @@ -615,30 +620,23 @@ func (*DarkTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) case CircleClassDefault: return StyleOverride{}, false - case CircleClassHome: + case CircleClassLocalPlanet: return StyleOverride{ - FillColor: nil, // cRGBA(120, 214, 198, 255), - StrokeColor: cRGBA(120, 214, 198, 255), - StrokeWidthPx: new(2.5), - }, true - - case CircleClassAcquired: - return StyleOverride{ - FillColor: nil, // cRGBA(155, 175, 235, 255), + FillColor: cRGBA(155, 175, 235, 255), StrokeColor: cRGBA(155, 175, 235, 255), StrokeWidthPx: new(2.2), }, true - case CircleClassOccupied: + case CircleClassOthersPlanet: return StyleOverride{ - FillColor: nil, // cRGBA(245, 178, 120, 255), + FillColor: cRGBA(245, 178, 120, 255), StrokeColor: cRGBA(245, 178, 120, 255), StrokeWidthPx: new(2.2), }, true - case CircleClassFree: + case CircleClassFreePlanet: return StyleOverride{ - FillColor: nil, // cRGBA(132, 219, 162, 255), + FillColor: cRGBA(132, 219, 162, 255), StrokeColor: cRGBA(132, 219, 162, 255), StrokeWidthPx: new(2.2), }, true diff --git a/client/world/world.go b/client/world/world.go index c51be40..5bdabe3 100644 --- a/client/world/world.go +++ b/client/world/world.go @@ -688,6 +688,8 @@ const ( PointClassTrackIncoming // PointClassTrackOutgoing marks a point as an outgoing track marker. PointClassTrackOutgoing + // PointClassUnidentifiedPlanet marks an unidentified planet without visivle size. + PointClassUnidentifiedPlanet ) // LineClassID classifies Line primitives for theme-level style overrides. @@ -711,14 +713,12 @@ type CircleClassID uint8 const ( // CircleClassDefault selects the theme's default circle styling. CircleClassDefault CircleClassID = iota - // CircleClassHome marks a circle as a home-world area. - CircleClassHome - // CircleClassAcquired marks a circle as an acquired world area. - CircleClassAcquired - // CircleClassOccupied marks a circle as an occupied world area. - CircleClassOccupied - // CircleClassFree marks a circle as a free world area. - CircleClassFree + // CircleClassLocalPlanet marks a circle as a player-owned planet. + CircleClassLocalPlanet + // CircleClassOthersPlanet marks a circle as an occupied planet. + CircleClassOthersPlanet + // CircleClassFreePlanet marks a circle as a free planet. + CircleClassFreePlanet ) // PrimitiveID is a compact stable identifier for primitives stored in the World. diff --git a/game/internal/controller/battle.go b/game/internal/controller/battle.go index 784ce0a..1250fbb 100644 --- a/game/internal/controller/battle.go +++ b/game/internal/controller/battle.go @@ -3,10 +3,10 @@ package controller import ( "iter" "maps" - "math" "math/rand/v2" "slices" + "galaxy/calc" "galaxy/game/internal/model/game" "github.com/google/uuid" @@ -65,7 +65,7 @@ func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[in return true } - p := DestructionProbability( + p := calc.DestructionProbability( c.ShipGroupShipClass(attIdx).Weapons.F(), c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(), c.ShipGroupShipClass(defIdx).Shields.F(), @@ -216,16 +216,6 @@ func SingleBattle(c *Cache, b *Battle) { } } -func DestructionProbability(attWeapons, attWeaponsTech, defShields, defShiledsTech, defFullMass float64) float64 { - effAttack := attWeapons * attWeaponsTech - effDefence := EffectiveDefence(defShields, defShiledsTech, defFullMass) - return (math.Log10(effAttack/effDefence)/math.Log10(4) + 1) / 2 -} - -func EffectiveDefence(defShields, defShiledsTech, defFullMass float64) float64 { - return defShields * defShiledsTech / math.Pow(defFullMass, 1./3.) * math.Pow(30., 1./3.) -} - func randomValue(v iter.Seq[int]) int { ids := slices.Collect(v) return ids[rand.IntN(len(ids))] diff --git a/game/internal/controller/battle_test.go b/game/internal/controller/battle_test.go index 391f6d0..9518c0c 100644 --- a/game/internal/controller/battle_test.go +++ b/game/internal/controller/battle_test.go @@ -5,6 +5,7 @@ import ( "slices" "testing" + "galaxy/calc" "galaxy/game/internal/controller" "galaxy/game/internal/model/game" @@ -39,25 +40,25 @@ var ( ) func TestDestructionProbability(t *testing.T) { - probability := controller.DestructionProbability(ship.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass()) + probability := calc.DestructionProbability(ship.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass()) assert.Equal(t, .5, probability) undefeatedShip := ship undefeatedShip.Shields = 55 - probability = controller.DestructionProbability(ship.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass()) + probability = calc.DestructionProbability(ship.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass()) assert.LessOrEqual(t, probability, 0.) disruptiveShip := ship disruptiveShip.Weapons = 40 - probability = controller.DestructionProbability(disruptiveShip.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass()) + probability = calc.DestructionProbability(disruptiveShip.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass()) assert.GreaterOrEqual(t, probability, 1.) } func TestEffectiveDefence(t *testing.T) { - assert.Equal(t, 10., controller.EffectiveDefence(ship.Shields.F(), 1, ship.EmptyMass())) + assert.Equal(t, 10., calc.EffectiveDefence(ship.Shields.F(), 1, ship.EmptyMass())) - attackerEffectiveDefence := controller.EffectiveDefence(attacker.Shields.F(), 1, attacker.EmptyMass()) - defenderEffectiveDefence := controller.EffectiveDefence(defender.Shields.F(), 1, defender.EmptyMass()) + attackerEffectiveDefence := calc.EffectiveDefence(attacker.Shields.F(), 1, attacker.EmptyMass()) + defenderEffectiveDefence := calc.EffectiveDefence(defender.Shields.F(), 1, defender.EmptyMass()) // attacker's effective shields must be 'just' 4 times greater than defender's assert.InDelta(t, defenderEffectiveDefence*4, attackerEffectiveDefence, 0) @@ -123,7 +124,7 @@ func TestFilterBattleOpponents(t *testing.T) { assert.True(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability)) assert.NoError(t, c.UpdateRelation(Race_0_idx, Race_1_idx, game.RelationWar)) - assert.LessOrEqual(t, controller.DestructionProbability(Cruiser.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass()), 0.) + assert.LessOrEqual(t, calc.DestructionProbability(Cruiser.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass()), 0.) assert.True(t, controller.FilterBattleOpponents(c, 1, 3, cacheProbability)) assert.NotContains(t, cacheProbability[1], 3) } diff --git a/game/internal/controller/planet.go b/game/internal/controller/planet.go index fade376..ffc5ef4 100644 --- a/game/internal/controller/planet.go +++ b/game/internal/controller/planet.go @@ -5,6 +5,7 @@ import ( "iter" "slices" + "galaxy/calc" "galaxy/util" e "galaxy/error" @@ -272,7 +273,7 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint { } ships := uint(0) pa := productionAvailable - PRODcost := ShipProductionCost(shipMass) + PRODcost := calc.ShipProductionCost(shipMass) var MATneed, MATfarm, totalCost float64 for { MATneed = shipMass - float64(p.Material) @@ -281,8 +282,6 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint { } MATfarm = MATneed / float64(p.Resources) totalCost = PRODcost + MATfarm - // fmt.Printf("PRODcost: %3.03f MATcost: %3.03f MAThave: %3.03f MATneed: %3.03f MATfarm: %3.03f total: %3.03f \n", - // PRODcost, shipMass, float64(p.Material), MATneed, MATfarm, totalCost) if pa < totalCost { progress := pa / totalCost pval := game.F(progress) @@ -292,7 +291,6 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint { p.Production.Progress = &pval fval := game.F(pa) p.Production.ProdUsed = &fval - // fmt.Println("pa", pa, "progress", progress, "MAT:", progress*shipMass) return ships } else { pa -= totalCost @@ -301,10 +299,3 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint { } } } - -func ShipProductionCost(shipMass float64) float64 { - return shipMass * 10. -} -func ShipMaterialCost(shipMass, planetResource float64) float64 { - return shipMass / planetResource -} diff --git a/game/internal/controller/report.go b/game/internal/controller/report.go index bf982e5..5620dcf 100644 --- a/game/internal/controller/report.go +++ b/game/internal/controller/report.go @@ -6,6 +6,7 @@ import ( "iter" "slices" + "galaxy/calc" mr "galaxy/model/report" "galaxy/util" @@ -540,7 +541,7 @@ func (c *Cache) ReportShipProduction(ri int, rep *mr.Report) { sliceIndexValidate(&rep.ShipProduction, i) rep.ShipProduction[pi].Planet = p.Number rep.ShipProduction[pi].Class = st.Name - rep.ShipProduction[pi].Cost = mr.F(ShipProductionCost(st.EmptyMass())) + rep.ShipProduction[pi].Cost = mr.F(calc.ShipProductionCost(st.EmptyMass())) rep.ShipProduction[pi].Free = mr.F(c.PlanetProductionCapacity(p.Number)) rep.ShipProduction[pi].ProdUsed = mr.F((*p.Production.ProdUsed).F()) rep.ShipProduction[pi].Percent = mr.F((*p.Production.Progress).F()) diff --git a/game/internal/controller/ship_class.go b/game/internal/controller/ship_class.go index 961cb16..ba03e55 100644 --- a/game/internal/controller/ship_class.go +++ b/game/internal/controller/ship_class.go @@ -5,6 +5,7 @@ import ( "iter" "slices" + "galaxy/calc" "galaxy/util" e "galaxy/error" @@ -16,7 +17,7 @@ import ( func (c *Cache) ShipClassCreate(ri int, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error { c.validateRaceIndex(ri) - if err := validateShipTypeValues(drive, ammo, weapons, shileds, cargo); err != nil { + if err := calc.ValidateShipTypeValues(drive, ammo, weapons, shileds, cargo); err != nil { return err } n, ok := util.ValidateTypeName(typeName) @@ -159,32 +160,3 @@ func (c *Cache) MustShipType(ri int, ID uuid.UUID) *game.ShipType { } panic(fmt.Sprintf("ship class not found: race_idx=%d id=%v", ri, ID)) } - -func validateShipTypeValues(d float64, a int, w, s, c float64) error { - if !checkShipTypeValueDWSC(d) { - return e.NewDriveValueError(d) - } - if !checkShipTypeValueDWSC(w) { - return e.NewWeaponsValueError(w) - } - if !checkShipTypeValueDWSC(s) { - return e.NewShieldsValueError(s) - } - if !checkShipTypeValueDWSC(c) { - return e.NewCargoValueError(s) - } - if a < 0 { - return e.NewShipTypeArmamentValueError(a) - } - if (w == 0 && a > 0) || (a == 0 && w > 0) { - return e.NewShipTypeArmamentAndWeaponsValueError("A=%d W=%.0f", a, w) - } - if d == 0 && w == 0 && s == 0 && c == 0 && a == 0 { - return e.NewShipTypeShipTypeZeroValuesError() - } - return nil -} - -func checkShipTypeValueDWSC(v float64) bool { - return v == 0 || v >= 1 -} diff --git a/game/internal/model/game/group.go b/game/internal/model/game/group.go index dfa44df..845c6d7 100644 --- a/game/internal/model/game/group.go +++ b/game/internal/model/game/group.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "galaxy/calc" "math" "strings" @@ -179,18 +180,13 @@ func (sg ShipGroup) Equal(other ShipGroup) bool { sg.State() == other.State() } -// Грузоподъёмность +// Грузоподъёмность группы func (sg ShipGroup) CargoCapacity(st *ShipType) float64 { - return sg.TechLevel(TechCargo).F() * (st.Cargo.F() + (st.Cargo.F()*st.Cargo.F())/20) * float64(sg.Number) + return calc.CargoCapacity(st.Cargo.F(), sg.TechLevel(TechCargo).F()) * float64(sg.Number) } -// Масса перевозимого груза - -// общее количество единиц груза, деленное на технологический уровень Грузоперевозок func (sg ShipGroup) CarryingMass() float64 { - if sg.Load.F() == 0 { - return 0 - } - return sg.Load.F() / sg.TechLevel(TechCargo).F() + return calc.CarryingMass(sg.Load.F(), sg.TechLevel(TechCargo).F()) } // Масса группы без учёта груза @@ -198,22 +194,16 @@ func (sg ShipGroup) EmptyMass(st *ShipType) float64 { return st.EmptyMass() * float64(sg.Number) } -// Полная масса - -// массу корабля самого по себе плюс масса перевозимого груза func (sg ShipGroup) FullMass(st *ShipType) float64 { - return sg.EmptyMass(st) + sg.CarryingMass() + return calc.FullMass(sg.EmptyMass(st), sg.CarryingMass()) } -// Эффективность двигателя - -// равна мощности Двигателей, умноженной на технологический уровень блока Двигателей func (sg ShipGroup) DriveEffective(st *ShipType) float64 { - return st.Drive.F() * sg.TechLevel(TechDrive).F() + return calc.DriveEffective(st.Drive.F(), sg.TechLevel(TechDrive).F()) } -// Корабли перемещаются за один ход на количество световых лет, равное -// эффективности двигателя, умноженной на 20 и деленной на "Полную массу" корабля func (sg ShipGroup) Speed(st *ShipType) float64 { - return sg.DriveEffective(st) * 20 / sg.FullMass(st) + return calc.Speed(sg.DriveEffective(st), sg.FullMass(st)) } // Мощность бомбардировки diff --git a/game/internal/model/game/ship.go b/game/internal/model/game/ship.go index 4de93c6..fe202cf 100644 --- a/game/internal/model/game/ship.go +++ b/game/internal/model/game/ship.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "galaxy/calc" "github.com/google/uuid" ) @@ -44,10 +45,11 @@ func (st ShipType) DriveBlockMass() float64 { } func (st ShipType) WeaponsBlockMass() float64 { - if (st.Armament == 0 && st.Weapons != 0) || (st.Armament != 0 && st.Weapons == 0) { + if v, ok := calc.WeaponsBlockMass(st.Weapons.F(), st.Armament); !ok { panic(fmt.Sprintf("ship class invalid design: A=%d W=%.03f", st.Armament, st.Weapons)) + } else { + return v } - return float64(st.Armament+1) * (st.Weapons.F() / 2) } func (st ShipType) ShieldsBlockMass() float64 { @@ -59,6 +61,10 @@ func (st ShipType) CargoBlockMass() float64 { } func (st ShipType) EmptyMass() float64 { - shipMass := st.DriveBlockMass() + st.ShieldsBlockMass() + st.CargoBlockMass() + st.WeaponsBlockMass() - return shipMass + if v, ok := calc.EmptyMass(st.Drive.F(), st.Weapons.F(), st.Armament, st.Shields.F(), st.Cargo.F()); !ok { + panic(fmt.Sprintf("ship class invalid design: D=%.03f A=%d W=%.03f S=%.03f C=%.03f", + st.Drive, st.Armament, st.Weapons, st.Shields, st.Cargo)) + } else { + return v + } } diff --git a/game/scripts/init.json b/game/scripts/init.json new file mode 100644 index 0000000..791b1ce --- /dev/null +++ b/game/scripts/init.json @@ -0,0 +1,24 @@ +{ + "races": [ + {"name": "Race_01"}, + {"name": "Race_02"}, + {"name": "Race_03"}, + {"name": "Race_04"}, + {"name": "Race_05"}, + {"name": "Race_06"}, + {"name": "Race_07"}, + {"name": "Race_08"}, + {"name": "Race_09"}, + {"name": "Race_10"}, + {"name": "Race_11"}, + {"name": "Race_12"}, + {"name": "Race_13"}, + {"name": "Race_14"}, + {"name": "Race_15"}, + {"name": "Race_16"}, + {"name": "Race_17"}, + {"name": "Race_18"}, + {"name": "Race_19"}, + {"name": "Race_20"} + ] +} diff --git a/game/scripts/init.sh b/game/scripts/init.sh new file mode 100755 index 0000000..2d8ccab --- /dev/null +++ b/game/scripts/init.sh @@ -0,0 +1,6 @@ +#/bin/bash + +curl -X POST \ + -H "Content-Type: application/json" \ + --data @init.json \ + http://127.0.0.1:8080/api/v1/init diff --git a/go.work b/go.work index 551854a..ae4c6c0 100644 --- a/go.work +++ b/go.work @@ -3,6 +3,7 @@ go 1.26.0 use ( ./client ./game + ./pkg/calc ./pkg/connector ./pkg/error ./pkg/model @@ -12,6 +13,7 @@ use ( ) replace ( + galaxy/calc v0.0.0 => ./pkg/calc galaxy/connector v0.0.0 => ./pkg/connector galaxy/error v0.0.0 => ./pkg/error galaxy/model v0.0.0 => ./pkg/model diff --git a/pkg/calc/go.mod b/pkg/calc/go.mod new file mode 100644 index 0000000..1acc9a2 --- /dev/null +++ b/pkg/calc/go.mod @@ -0,0 +1,3 @@ +module galaxy/calc + +go 1.26.0 diff --git a/pkg/calc/planet.go b/pkg/calc/planet.go new file mode 100644 index 0000000..ba1a325 --- /dev/null +++ b/pkg/calc/planet.go @@ -0,0 +1,13 @@ +package calc + +func ShipProductionCost(shipEmptyMass float64) float64 { + return shipEmptyMass * 10. +} + +func PlanetProduceShipMass(L, Mat, Res float64) float64 { + result := L / 10 + if result <= Mat { + return result + } + return (L + Mat/Res) / (10 + 1/Res) +} diff --git a/pkg/calc/ship.go b/pkg/calc/ship.go new file mode 100644 index 0000000..0b96271 --- /dev/null +++ b/pkg/calc/ship.go @@ -0,0 +1,85 @@ +package calc + +import "math" + +// Эффективность двигателя - +// равна мощности Двигателей, умноженной на технологический уровень блока Двигателей +func DriveEffective(drive, driveTech float64) float64 { + return drive * driveTech +} + +// Масса перевозимого груза - +// общее количество единиц груза, деленное на технологический уровень Грузоперевозок +func CarryingMass(load, cargoTech float64) float64 { + if load <= 0 { + return 0 + } + return load / cargoTech +} + +// Грузоподъёмность одного корабля +func CargoCapacity(cargo, cargoTech float64) float64 { + return cargoTech * (cargo + (cargo*cargo)/20) +} + +// Корабли перемещаются за один ход на количество световых лет, равное +// эффективности двигателя, умноженной на 20 и деленной на "Полную массу" корабля +func Speed(driveEffective, fullMass float64) float64 { + if fullMass <= 0 { + return 0 + } + return driveEffective * 20 / fullMass +} + +// Полная масса - +// массу корабля самого по себе плюс масса перевозимого груза +func FullMass(emptyMass, carryingMass float64) float64 { + return emptyMass + carryingMass +} + +func EmptyMass(drive, weapons float64, armament uint, shields, cargo float64) (float64, bool) { + wm, ok := WeaponsBlockMass(weapons, armament) + if !ok { + return 0, false + } + return drive + shields + cargo + wm, true +} + +func WeaponsBlockMass(weapons float64, armament uint) (float64, bool) { + if (armament == 0 && weapons != 0) || (armament != 0 && weapons == 0) { + return 0, false + } + return float64(armament+1) * (weapons / 2), true +} + +func DestructionProbability( + attackingWeapons, + attackingWeaponsTech, + defendingShields, + defendingShiledsTech, + defendingFullMass float64, +) float64 { + return DestructionProbabilityEffective( + EffectiveAttack(attackingWeapons, attackingWeaponsTech), + EffectiveDefence(defendingShields, defendingShiledsTech, defendingFullMass), + ) +} + +func DestructionProbabilityEffective(effectiveAttack, effectiveDefence float64) float64 { + return (math.Log10(effectiveAttack/effectiveDefence)/math.Log10(4) + 1) / 2 +} + +func EffectiveAttack(weapons, weaponsTech float64) float64 { + return weapons * weaponsTech +} + +func EffectiveDefence( + defendingShields, + defendingShiledsTech, + defendingFullMass float64, +) float64 { + if defendingFullMass <= 0 { + return 0 + } + return defendingShields * defendingShiledsTech / math.Pow(defendingFullMass, 1./3.) * math.Pow(30., 1./3.) +} diff --git a/pkg/calc/validator.go b/pkg/calc/validator.go new file mode 100644 index 0000000..7d7a1d2 --- /dev/null +++ b/pkg/calc/validator.go @@ -0,0 +1,34 @@ +package calc + +import ( + e "galaxy/error" +) + +func ValidateShipTypeValues(d float64, a int, w, s, c float64) error { + if !CheckShipTypeValueDWSC(d) { + return e.NewDriveValueError(d) + } + if !CheckShipTypeValueDWSC(w) { + return e.NewWeaponsValueError(w) + } + if !CheckShipTypeValueDWSC(s) { + return e.NewShieldsValueError(s) + } + if !CheckShipTypeValueDWSC(c) { + return e.NewCargoValueError(s) + } + if a < 0 { + return e.NewShipTypeArmamentValueError(a) + } + if (w == 0 && a > 0) || (a == 0 && w > 0) { + return e.NewShipTypeArmamentAndWeaponsValueError("A=%d W=%.0f", a, w) + } + if d == 0 && w == 0 && s == 0 && c == 0 && a == 0 { + return e.NewShipTypeShipTypeZeroValuesError() + } + return nil +} + +func CheckShipTypeValueDWSC(v float64) bool { + return v == 0 || v >= 1 +} diff --git a/pkg/model/client/client.go b/pkg/model/client/client.go index 925eed6..6df4e3e 100644 --- a/pkg/model/client/client.go +++ b/pkg/model/client/client.go @@ -41,6 +41,13 @@ type State struct { ClientNextVersion *string `json:"clientNextVersion,omitempty"` GameState []GameState `json:"gameState,omitempty"` ActiveGameID *GameID `json:"activeGameId,omitempty"` + + CameraZoom float64 `json:"cameraZoom"` + CameraXFp int `json:"cameraXFp"` + CameraYFp int `json:"cameraYFp"` + MapSplitterOffset float64 `json:"mapSplitterOffset"` + AccordionInfoOpen bool `json:"accInfoOpen"` + AccordionCalcOpen bool `json:"accCalcOpen"` } type GameState struct { diff --git a/pkg/storage/fs/fs.go b/pkg/storage/fs/fs.go index 7519cb9..8ce367f 100644 --- a/pkg/storage/fs/fs.go +++ b/pkg/storage/fs/fs.go @@ -128,6 +128,15 @@ func (s *fsStorage) SaveStateAsync(state client.State, callback func(error)) { }() } +func (s *fsStorage) ReportExistsAsync(id client.GameID, turn uint, callback func(bool, error)) { + go func() { + exists, err := s.gameDataExistsSync(id, turn) + if callback != nil { + callback(exists, err) + } + }() +} + func (s *fsStorage) LoadReportAsync(id client.GameID, turn uint, callback func(report.Report, error)) { go func() { rep, err := s.loadReportSync(id, turn) @@ -343,6 +352,20 @@ func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) er })) } +func (s *fsStorage) gameDataExistsSync(id client.GameID, turn uint) (bool, error) { + absPath, err := s.resolvePath(gameTurnFilePath(id, turn)) + if err != nil { + return false, classifyStorageError(err) + } + + exists, err := s.fileExistsUnlocked(absPath) + if err != nil { + return false, classifyStorageError(err) + } + + return exists, nil +} + func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) { absPath, err := s.resolvePath(gameTurnFilePath(id, turn)) if err != nil { diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index f5a760f..f6aabdd 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -36,6 +36,11 @@ type UIStorage interface { // I/O or encoding error may occur, it that case callback func will be called with non-nil error. SaveStateAsync(client.State, func(error)) + // ReportExistsAsync asynchronously checks whether given [model.GameID] and turn number exists in the Storage. + // Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried, + // otherwise callback func accepts boolean result. + ReportExistsAsync(client.GameID, uint, func(bool, error)) + // LoadReportAsync loads a [report.Report] for a given [model.GameID] and turn number from filesystem asynchronously. // Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried, // otherwise callback func accepts loaded [report.Report].