From f87b59390e07e05a4dc504e5ea9941796c69b2e1 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 21:04:49 +0100 Subject: [PATCH] Co-op offline with an AI partner Co-Authored-By: Claude Opus 5.5 --- docs/MULTIPLAYER.md | 7 +++ lang/cs.lang | 3 + lang/da.lang | 3 + lang/de.lang | 3 + lang/el.lang | 3 + lang/en.lang | 3 + lang/es.lang | 3 + lang/fi.lang | 3 + lang/hr.lang | 3 + lang/hu.lang | 3 + lang/it.lang | 3 + lang/nl.lang | 3 + lang/no.lang | 3 + lang/pt.lang | 3 + lang/ro.lang | 3 + lang/ru.lang | 3 + lang/sk.lang | 3 + lang/sr.lang | 3 + lang/sv.lang | 3 + src/main.rs | 69 ++++++++++++++++++++- src/menu.rs | 5 ++ src/rival.rs | 142 +++++++++++++++++++++++++++++++++++++++++++- 22 files changed, 272 insertions(+), 5 deletions(-) diff --git a/docs/MULTIPLAYER.md b/docs/MULTIPLAYER.md index 20d59b2..ecb2fc6 100644 --- a/docs/MULTIPLAYER.md +++ b/docs/MULTIPLAYER.md @@ -90,6 +90,13 @@ per turn. Fog of war, particles and the camera are local and not part of the sha five strongest creatures and 20% of his gold, like the single-player `Carry`. The host stores the progress (missions done, chosen branches, run seed) in local storage under `dc.coop_editor_progress` (campaign name -> `CampaignProgress`). A run ends when no next mission fits co-op. +- **Co-op offline with an AI partner** (*AI partner* on the Match page, works without the server): the selected + co-op mission (built-in, the league or the editor campaign) starts like a match with the player on side 0 and + an AI seat on side 2 (`App::start_offline_coop`: `start_match` + `sync_ai_sides`), then `end_match` drops the + turn clock and the world runs on this computer like a single-player game. The partner (`Game::is_partner`) is + an allied rival-style master with his own dungeon: two of three healthy creatures follow the player's attack + flag (and attack the gate next to it), and every minute he sends 400 gold to an ally below 300. An editor + campaign run goes on locally: the next mission (or the same after a defeat) with the run seed and the carry. - **Teams in versus**: on the Match page every seat has a team A-D (default: a team of its own), set with `{"t":"set_team","team":n}` for the own seat; the host may add `"side"` for a free or AI seat. Only before the start. Room updates carry `team` per seat, the room list `teams`, and `start` a `teams` list in the order of diff --git a/lang/cs.lang b/lang/cs.lang index d410602..37d09d1 100644 --- a/lang/cs.lang +++ b/lang/cs.lang @@ -662,6 +662,8 @@ net.coop_create = Kooperace net.coop_editor = Kampaň z editoru net.coop_builtin = Vestavěná kampaň net.coop_league = Liga proti Světlé stráži +net.coop_solo = AI partner +net.coop_solo_started = Kooperace bez serveru: tvůj AI partner si staví vlastní kobku, následuje tvou útočnou vlajku a pomáhá zlatem. net.coop_editor_mission = Kampaň z editoru {0}: mise {1} ({2}) net.coop_editor_none = Žádná mise kampaně z editoru se nehodí pro kooperaci (mapa aspoň 72x72, jiný cíl než soupeřící mistři) net.join = Připojit @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Vlna {0}: hrdinové ({1}) táhnou na zeleného mistra. msg.wave_on_rival2 = Vlna {0}: hrdinové ({1}) táhnou na modrou mistryni. msg.wave_warn_on_master = Zvědové hlásí: vlna hrdinů {0} dorazí za 30 sekund a míří na pána {1}. msg.wave_on_master = Vlna {0}: {1} hrdinů táhne na pána {2}. +msg.partner_out = Srdce tvého AI partnera padlo – bojuj dál sám! hud.rival1 = Zelený hud.rival2 = Modrá msg.creature_freed = Tvor přetrhl řetězy a přidal se k tobě: {0}! diff --git a/lang/da.lang b/lang/da.lang index c29835f..7b2ff78 100644 --- a/lang/da.lang +++ b/lang/da.lang @@ -661,6 +661,8 @@ net.coop_create = Co-op-kampagne net.coop_editor = Editor-kampagne net.coop_builtin = Indbygget kampagne net.coop_league = Forbundet mod Lysvagten +net.coop_solo = AI-partner +net.coop_solo_started = Co-op uden server: din AI-partner bygger sit eget fangehul, følger dit angrebsflag og hjælper med guld. net.coop_editor_mission = Editor-kampagne {0}: mission {1} ({2}) net.coop_editor_none = Ingen mission i editor-kampagnen passer til co-op (kort mindst 72x72, et mål ud over rivaliserende mestre) net.join = Deltag @@ -708,6 +710,7 @@ msg.wave_on_rival1 = Bølge {0}: {1} helte marcherer mod den grønne mester. msg.wave_on_rival2 = Bølge {0}: {1} helte marcherer mod den blå mester. msg.wave_warn_on_master = Spejdere melder: heltebølge {0} kommer om 30 sekunder og går mod mester {1}. msg.wave_on_master = Bølge {0}: {1} helte marcherer mod mester {2}. +msg.partner_out = Din AI-partners hjerte er faldet – kæmp videre alene! hud.rival1 = Grøn hud.rival2 = Blå msg.creature_freed = En {0} sprænger sine lænker og slutter sig til dig! diff --git a/lang/de.lang b/lang/de.lang index b1ddc83..6198c59 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -661,6 +661,8 @@ net.coop_create = Koop-Kampagne net.coop_editor = Editor-Kampagne net.coop_builtin = Eingebaute Kampagne net.coop_league = Bund gegen die Lichtwacht +net.coop_solo = KI-Partner +net.coop_solo_started = Koop ohne Server: Dein KI-Partner baut einen eigenen Kerker, folgt deiner Angriffsflagge und hilft mit Gold aus. net.coop_editor_mission = Editor-Kampagne {0}: Mission {1} ({2}) net.coop_editor_none = Keine Mission der Editor-Kampagne passt für Koop (Karte mindestens 72x72, ein Ziel außer Rivalen-Meistern) net.join = Beitreten @@ -708,6 +710,7 @@ msg.wave_on_rival1 = Welle {0}: {1} Helden ziehen gegen den grünen Meister. msg.wave_on_rival2 = Welle {0}: {1} Helden ziehen gegen den blauen Meister. msg.wave_warn_on_master = Späher melden: Heldenwelle {0} kommt in 30 Sekunden – sie zieht gegen Meister {1}. msg.wave_on_master = Welle {0}: {1} Helden ziehen gegen Meister {2}. +msg.partner_out = Das Herz deines KI-Partners ist gefallen – kämpfe allein weiter! hud.rival1 = Grün hud.rival2 = Blau msg.creature_freed = Ein {0} sprengt seine Ketten und schließt sich dir an! diff --git a/lang/el.lang b/lang/el.lang index 4a90970..759c054 100644 --- a/lang/el.lang +++ b/lang/el.lang @@ -662,6 +662,8 @@ net.coop_create = Συνεργατική εκστρ. net.coop_editor = Εκστρατεία επεξεργαστή net.coop_builtin = Ενσωματωμένη εκστρατεία net.coop_league = Συμμαχία κατά της Φωτοφρουράς +net.coop_solo = Σύμμαχος ΤΝ +net.coop_solo_started = Συνεργασία χωρίς διακομιστή: ο σύμμαχός σου ΤΝ χτίζει το δικό του μπουντρούμι, ακολουθεί τη σημαία επίθεσής σου και βοηθά με χρυσό. net.coop_editor_mission = Εκστρατεία επεξεργαστή {0}: αποστολή {1} ({2}) net.coop_editor_none = Καμία αποστολή της εκστρατείας επεξεργαστή δεν ταιριάζει για συνεργασία (χάρτης τουλάχιστον 72x72, ένας στόχος πέρα από τους αντίπαλους άρχοντες) net.join = Συμμετοχή @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Κύμα {0}: {1} ήρωες βαδίζουν κατά το msg.wave_on_rival2 = Κύμα {0}: {1} ήρωες βαδίζουν κατά της γαλάζιας άρχοντος. msg.wave_warn_on_master = Οι ανιχνευτές αναφέρουν: το κύμα ηρώων {0} έρχεται σε 30 δευτερόλεπτα και κατευθύνεται στον άρχοντα {1}. msg.wave_on_master = Κύμα {0}: {1} ήρωες βαδίζουν κατά του άρχοντα {2}. +msg.partner_out = Η καρδιά του συμμάχου σου ΤΝ έπεσε – πολέμησε μόνος! hud.rival1 = Πράσινος hud.rival2 = Γαλάζια msg.creature_freed = Σπάει τις αλυσίδες του και έρχεται μαζί σου: {0}! diff --git a/lang/en.lang b/lang/en.lang index 0586072..a9eef6d 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -662,6 +662,8 @@ net.coop_create = Co-op campaign net.coop_editor = Editor campaign net.coop_builtin = Built-in campaign net.coop_league = League against the Lightwatch +net.coop_solo = AI partner +net.coop_solo_started = Co-op without the server: your AI partner builds its own dungeon, follows your attack flag and helps out with gold. net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) net.join = Join @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Wave {0}: {1} heroes march on the green master. msg.wave_on_rival2 = Wave {0}: {1} heroes march on the blue master. msg.wave_warn_on_master = Scouts report: hero wave {0} comes in 30 seconds – it heads for master {1}. msg.wave_on_master = Wave {0}: {1} heroes march on master {2}. +msg.partner_out = Your AI partner's heart has fallen – fight on alone! hud.rival1 = Green hud.rival2 = Blue msg.creature_freed = A {0} breaks its chains and joins you! diff --git a/lang/es.lang b/lang/es.lang index dccaaba..ecb5e59 100644 --- a/lang/es.lang +++ b/lang/es.lang @@ -704,6 +704,7 @@ msg.wave_on_rival1 = Oleada {0}: {1} héroes marchan contra el señor verde. msg.wave_on_rival2 = Oleada {0}: {1} héroes marchan contra la señora azul. msg.wave_warn_on_master = Los exploradores informan: la oleada {0} llega en 30 segundos y va a por el maestro {1}. msg.wave_on_master = Oleada {0}: {1} héroes marchan contra el maestro {2}. +msg.partner_out = ¡El corazón de tu socio IA ha caído: sigue luchando solo! hud.rival1 = Verde hud.rival2 = Azul msg.creature_freed = ¡Un {0} rompe sus cadenas y se une a ti! @@ -1333,6 +1334,8 @@ demo.m2.wave = Han pasado dos minutos: llegan los héroes. net.coop_editor = Campaña del editor net.coop_builtin = Campaña integrada net.coop_league = Liga contra la Guardia Luminosa +net.coop_solo = Socio IA +net.coop_solo_started = Cooperativo sin servidor: tu socio IA construye su propia mazmorra, sigue tu bandera de ataque y te ayuda con oro. net.coop_editor_mission = Campaña del editor {0}: misión {1} ({2}) net.coop_editor_none = Ninguna misión de la campaña del editor sirve para cooperativo (mapa de al menos 72x72, un objetivo aparte de los maestros rivales) blk.until = Repetir hasta diff --git a/lang/fi.lang b/lang/fi.lang index 0718dda..6c37df9 100644 --- a/lang/fi.lang +++ b/lang/fi.lang @@ -662,6 +662,8 @@ net.coop_create = Yhteiskampanja net.coop_editor = Editorikampanja net.coop_builtin = Sisäinen kampanja net.coop_league = Liitto Valonvartiota vastaan +net.coop_solo = Kumppani +net.coop_solo_started = Yhteistyö ilman palvelinta: tekoälykumppanisi rakentaa oman luolastonsa, seuraa hyökkäyslippuasi ja auttaa kullalla. net.coop_editor_mission = Editorikampanja {0}: tehtävä {1} ({2}) net.coop_editor_none = Mikään editorikampanjan tehtävä ei sovi yhteispeliin (kartta vähintään 72x72, muukin tavoite kuin kilpailevat mestarit) net.join = Liity @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Aalto {0}: {1} sankaria marssii vihreää mestaria vastaan. msg.wave_on_rival2 = Aalto {0}: {1} sankaria marssii sinistä mestaria vastaan. msg.wave_warn_on_master = Tiedustelijat ilmoittavat: sankariaalto {0} saapuu 30 sekunnissa ja hyökkää mestari {1} vastaan. msg.wave_on_master = Aalto {0}: {1} sankaria marssii mestari {2} vastaan. +msg.partner_out = Tekoälykumppanisi sydän on murtunut – taistele yksin! hud.rival1 = Vihreä hud.rival2 = Sininen msg.creature_freed = Kahleet katkeavat ja uusi olento liittyy joukkoosi: {0}! diff --git a/lang/hr.lang b/lang/hr.lang index 16ea55b..577599b 100644 --- a/lang/hr.lang +++ b/lang/hr.lang @@ -662,6 +662,8 @@ net.coop_create = Co-op kampanja net.coop_editor = Kampanja uređivača net.coop_builtin = Ugrađena kampanja net.coop_league = Savez protiv Svjetlostraže +net.coop_solo = AI partner +net.coop_solo_started = Suradnja bez poslužitelja: tvoj AI partner gradi vlastitu tamnicu, slijedi tvoju zastavu napada i pomaže zlatom. net.coop_editor_mission = Kampanja uređivača {0}: misija {1} ({2}) net.coop_editor_none = Nijedna misija kampanje uređivača ne odgovara co-op igri (karta barem 72x72, cilj osim suparničkih gospodara) net.join = Pridruži @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Val {0}: junaci ({1}) marširaju na zelenog gospodara. msg.wave_on_rival2 = Val {0}: junaci ({1}) marširaju na plavu gospodaricu. msg.wave_warn_on_master = Izvidnici javljaju: val junaka {0} stiže za 30 sekundi i kreće na gospodara {1}. msg.wave_on_master = Val {0}: {1} junaka maršira na gospodara {2}. +msg.partner_out = Srce tvog AI partnera je palo – bori se dalje sam! hud.rival1 = Zeleni hud.rival2 = Plava msg.creature_freed = Raskida okove i pridružuje ti se: {0}! diff --git a/lang/hu.lang b/lang/hu.lang index 86237e7..0e76dcf 100644 --- a/lang/hu.lang +++ b/lang/hu.lang @@ -662,6 +662,8 @@ net.coop_create = Koop hadjárat net.coop_editor = Saját hadjárat net.coop_builtin = Beépített hadjárat net.coop_league = Szövetség a Fényőrség ellen +net.coop_solo = MI-társ +net.coop_solo_started = Kooperatív szerver nélkül: MI-társad saját kazamatát épít, követi a támadási zászlódat és arannyal segít. net.coop_editor_mission = Saját hadjárat ({0}): {1}. küldetés ({2}) net.coop_editor_none = A saját hadjárat egyik küldetése sem alkalmas kooperatív játékra (térkép legalább 72x72, a rivális mestereken kívül még egy cél) net.join = Belép @@ -709,6 +711,7 @@ msg.wave_on_rival1 = {0}. hullám: {1} hős vonul a zöld mester ellen. msg.wave_on_rival2 = {0}. hullám: {1} hős vonul a kék mester ellen. msg.wave_warn_on_master = A felderítők jelentik: a(z) {0}. hősroham 30 másodperc múlva érkezik, és {1} mester ellen vonul. msg.wave_on_master = {0}. roham: {1} hős vonul {2} mester ellen. +msg.partner_out = MI-társad szíve elesett – harcolj tovább egyedül! hud.rival1 = Zöld hud.rival2 = Kék msg.creature_freed = Széttépi a láncait, és hozzád csatlakozik: {0}! diff --git a/lang/it.lang b/lang/it.lang index 7f952b3..05509da 100644 --- a/lang/it.lang +++ b/lang/it.lang @@ -704,6 +704,7 @@ msg.wave_on_rival1 = Ondata {0}: {1} eroi marciano sul maestro verde. msg.wave_on_rival2 = Ondata {0}: {1} eroi marciano sulla maestra blu. msg.wave_warn_on_master = Gli esploratori riferiscono: l'ondata {0} arriva tra 30 secondi e punta sul maestro {1}. msg.wave_on_master = Ondata {0}: {1} eroi marciano contro il maestro {2}. +msg.partner_out = Il cuore del tuo alleato IA è caduto: combatti da solo! hud.rival1 = Verde hud.rival2 = Blu msg.creature_freed = Spezza le catene e si unisce a te: {0}! @@ -1333,6 +1334,8 @@ demo.m2.wave = Sono passati due minuti: arrivano gli eroi. net.coop_editor = Campagna dell'editor net.coop_builtin = Campagna integrata net.coop_league = Lega contro la Guardia della Luce +net.coop_solo = Alleato IA +net.coop_solo_started = Cooperativa senza server: il tuo alleato IA costruisce il suo dungeon, segue la tua bandiera d'attacco e ti aiuta con l'oro. net.coop_editor_mission = Campagna dell'editor {0}: missione {1} ({2}) net.coop_editor_none = Nessuna missione della campagna dell'editor è adatta alla cooperativa (mappa almeno 72x72, un obiettivo oltre ai maestri rivali) blk.until = Ripeti finché diff --git a/lang/nl.lang b/lang/nl.lang index 4d38831..29e255e 100644 --- a/lang/nl.lang +++ b/lang/nl.lang @@ -662,6 +662,8 @@ net.coop_create = Co-op-veldtocht net.coop_editor = Editorveldtocht net.coop_builtin = Vaste veldtocht net.coop_league = Liga tegen de Lichtwacht +net.coop_solo = AI-partner +net.coop_solo_started = Co-op zonder server: je AI-partner bouwt een eigen kerker, volgt je aanvalsvlag en helpt met goud. net.coop_editor_mission = Editorveldtocht {0}: missie {1} ({2}) net.coop_editor_none = Geen missie van de editorveldtocht is geschikt voor co-op (kaart minstens 72x72, een doel naast rivaliserende meesters) net.join = Meedoen @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Golf {0}: {1} helden trekken op tegen de groene meester. msg.wave_on_rival2 = Golf {0}: {1} helden trekken op tegen de blauwe meester. msg.wave_warn_on_master = Verkenners melden: heldengolf {0} komt over 30 seconden en trekt naar meester {1}. msg.wave_on_master = Golf {0}: {1} helden trekken op tegen meester {2}. +msg.partner_out = Het hart van je AI-partner is gevallen – vecht alleen verder! hud.rival1 = Groen hud.rival2 = Blauw msg.creature_freed = Een {0} breekt zijn ketens en sluit zich bij je aan! diff --git a/lang/no.lang b/lang/no.lang index d3a206e..b35466d 100644 --- a/lang/no.lang +++ b/lang/no.lang @@ -661,6 +661,8 @@ net.coop_create = Co-op-kampanje net.coop_editor = Editorkampanje net.coop_builtin = Innebygd kampanje net.coop_league = Forbundet mot Lysvakten +net.coop_solo = KI-partner +net.coop_solo_started = Samarbeid uten server: KI-partneren din bygger sitt eget fangehull, følger angrepsflagget ditt og hjelper med gull. net.coop_editor_mission = Editorkampanje {0}: oppdrag {1} ({2}) net.coop_editor_none = Ingen oppdrag i editorkampanjen passer for co-op (kart minst 72x72, et mål i tillegg til rivaliserende mestere) net.join = Bli med @@ -708,6 +710,7 @@ msg.wave_on_rival1 = Bølge {0}: {1} helter marsjerer mot den grønne mesteren. msg.wave_on_rival2 = Bølge {0}: {1} helter marsjerer mot den blå mesteren. msg.wave_warn_on_master = Speidere melder: heltebølge {0} kommer om 30 sekunder og går mot mester {1}. msg.wave_on_master = Bølge {0}: {1} helter marsjerer mot mester {2}. +msg.partner_out = Hjertet til KI-partneren din har falt – kjemp videre alene! hud.rival1 = Grønn hud.rival2 = Blå msg.creature_freed = En {0} sprenger lenkene sine og slutter seg til deg! diff --git a/lang/pt.lang b/lang/pt.lang index cbf720c..cdb91d0 100644 --- a/lang/pt.lang +++ b/lang/pt.lang @@ -662,6 +662,8 @@ net.coop_create = Campanha coop. net.coop_editor = Campanha do editor net.coop_builtin = Campanha incluída net.coop_league = Liga contra a Guarda da Luz +net.coop_solo = Parceiro IA +net.coop_solo_started = Cooperativo sem servidor: o teu parceiro IA constrói a sua própria masmorra, segue a tua bandeira de ataque e ajuda com ouro. net.coop_editor_mission = Campanha do editor {0}: missão {1} ({2}) net.coop_editor_none = Nenhuma missão da campanha do editor serve para cooperativo (mapa de pelo menos 72x72, um objetivo além dos mestres rivais) net.join = Entrar @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Vaga {0}: {1} heróis marcham contra o mestre verde. msg.wave_on_rival2 = Vaga {0}: {1} heróis marcham contra a mestra azul. msg.wave_warn_on_master = Os batedores informam: a onda {0} chega em 30 segundos e vai contra o mestre {1}. msg.wave_on_master = Onda {0}: {1} heróis marcham contra o mestre {2}. +msg.partner_out = O coração do teu parceiro IA caiu – continua a lutar sozinho! hud.rival1 = Verde hud.rival2 = Azul msg.creature_freed = Um {0} parte as correntes e junta-se a ti! diff --git a/lang/ro.lang b/lang/ro.lang index 59994f5..0f797e5 100644 --- a/lang/ro.lang +++ b/lang/ro.lang @@ -662,6 +662,8 @@ net.coop_create = Campanie co-op net.coop_editor = Campanie din editor net.coop_builtin = Campanie inclusă net.coop_league = Liga împotriva Străjii Luminii +net.coop_solo = Partener IA +net.coop_solo_started = Cooperativ fără server: partenerul tău IA își construiește propria temniță, îți urmează steagul de atac și te ajută cu aur. net.coop_editor_mission = Campania din editor {0}: misiunea {1} ({2}) net.coop_editor_none = Nicio misiune din campania editorului nu se potrivește pentru co-op (hartă de cel puțin 72x72, un obiectiv în afară de stăpânii rivali) net.join = Intră @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Valul {0}: {1} eroi mărșăluiesc asupra stăpânului verd msg.wave_on_rival2 = Valul {0}: {1} eroi mărșăluiesc asupra stăpânei albastre. msg.wave_warn_on_master = Cercetașii raportează: valul de eroi {0} sosește în 30 de secunde și merge spre stăpânul {1}. msg.wave_on_master = Valul {0}: {1} eroi mărșăluiesc spre stăpânul {2}. +msg.partner_out = Inima partenerului tău IA a căzut – luptă mai departe singur! hud.rival1 = Verde hud.rival2 = Albastru msg.creature_freed = Un {0} își rupe lanțurile și ți se alătură! diff --git a/lang/ru.lang b/lang/ru.lang index 722c42a..9aa5da2 100644 --- a/lang/ru.lang +++ b/lang/ru.lang @@ -704,6 +704,7 @@ msg.wave_on_rival1 = Волна {0}: {1} героев идут на зелёно msg.wave_on_rival2 = Волна {0}: {1} героев идут на синюю хозяйку. msg.wave_warn_on_master = Разведчики докладывают: волна {0} будет через 30 секунд и идёт на мастера {1}. msg.wave_on_master = Волна {0}: {1} героев идут на мастера {2}. +msg.partner_out = Сердце вашего ИИ-союзника пало – сражайтесь в одиночку! hud.rival1 = Зелёный hud.rival2 = Синяя msg.creature_freed = Существо рвёт цепи и присоединяется к тебе: {0}! @@ -1333,6 +1334,8 @@ demo.m2.wave = Две минуты прошли: идут герои. net.coop_editor = Кампания из редактора net.coop_builtin = Встроенная кампания net.coop_league = Союз против Светлого Дозора +net.coop_solo = ИИ-союзник +net.coop_solo_started = Кооператив без сервера: ИИ-союзник строит своё подземелье, идёт к вашему флагу атаки и помогает золотом. net.coop_editor_mission = Кампания из редактора {0}: миссия {1} ({2}) net.coop_editor_none = Ни одна миссия кампании из редактора не подходит для кооператива (карта не меньше 72x72, цель помимо мастеров-соперников) blk.until = Повторять, пока не diff --git a/lang/sk.lang b/lang/sk.lang index 57a56cc..61d941b 100644 --- a/lang/sk.lang +++ b/lang/sk.lang @@ -662,6 +662,8 @@ net.coop_create = Kooperácia net.coop_editor = Kampaň z editora net.coop_builtin = Vstavaná kampaň net.coop_league = Liga proti Svetlej stráži +net.coop_solo = AI partner +net.coop_solo_started = Kooperácia bez servera: tvoj AI partner si stavia vlastnú kobku, nasleduje tvoju útočnú vlajku a pomáha zlatom. net.coop_editor_mission = Kampaň z editora {0}: misia {1} ({2}) net.coop_editor_none = Žiadna misia kampane z editora sa nehodí na kooperáciu (mapa aspoň 72x72, iný cieľ než súperiaci majstri) net.join = Pripojiť @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Vlna {0}: hrdinovia ({1}) tiahnu na zeleného majstra. msg.wave_on_rival2 = Vlna {0}: hrdinovia ({1}) tiahnu na modrú majsterku. msg.wave_warn_on_master = Prieskumníci hlásia: vlna hrdinov {0} príde o 30 sekúnd a mieri na pána {1}. msg.wave_on_master = Vlna {0}: {1} hrdinov pochoduje na pána {2}. +msg.partner_out = Srdce tvojho AI partnera padlo – bojuj ďalej sám! hud.rival1 = Zelený hud.rival2 = Modrá msg.creature_freed = Tvor roztrhol reťaze a pridal sa k tebe: {0}! diff --git a/lang/sr.lang b/lang/sr.lang index dc0627c..f4d20c6 100644 --- a/lang/sr.lang +++ b/lang/sr.lang @@ -662,6 +662,8 @@ net.coop_create = Ко-оп кампања net.coop_editor = Кампања уређивача net.coop_builtin = Уграђена кампања net.coop_league = Савез против Светлостраже +net.coop_solo = АИ партнер +net.coop_solo_started = Сарадња без сервера: твој АИ партнер гради сопствену тамницу, прати твоју заставу напада и помаже златом. net.coop_editor_mission = Кампања уређивача {0}: мисија {1} ({2}) net.coop_editor_none = Ниједна мисија кампање уређивача не одговара ко-оп игри (мапа најмање 72x72, циљ осим супарничких господара) net.join = Придружи @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Талас {0}: хероји ({1}) марширају на msg.wave_on_rival2 = Талас {0}: хероји ({1}) марширају на плаву господарицу. msg.wave_warn_on_master = Извиђачи јављају: талас јунака {0} стиже за 30 секунди и креће на господара {1}. msg.wave_on_master = Талас {0}: {1} јунака маршира на господара {2}. +msg.partner_out = Срце твог АИ партнера је пало – бори се даље сам! hud.rival1 = Зелени hud.rival2 = Плава msg.creature_freed = Раскида окове и придружује ти се: {0}! diff --git a/lang/sv.lang b/lang/sv.lang index c4a13b3..15e6d40 100644 --- a/lang/sv.lang +++ b/lang/sv.lang @@ -662,6 +662,8 @@ net.coop_create = Co-op-kampanj net.coop_editor = Egen kampanj net.coop_builtin = Inbyggd kampanj net.coop_league = Förbundet mot Ljusvakten +net.coop_solo = AI-partner +net.coop_solo_started = Samarbete utan server: din AI-partner bygger en egen fängelsehåla, följer din attackflagga och hjälper till med guld. net.coop_editor_mission = Egen kampanj {0}: uppdrag {1} ({2}) net.coop_editor_none = Inget uppdrag i redigerarens kampanj passar för co-op (karta minst 72x72, ett mål utöver rivaliserande mästare) net.join = Gå med @@ -709,6 +711,7 @@ msg.wave_on_rival1 = Våg {0}: {1} hjältar marscherar mot den gröna mästaren. msg.wave_on_rival2 = Våg {0}: {1} hjältar marscherar mot den blå mästaren. msg.wave_warn_on_master = Spejare rapporterar: hjältevåg {0} kommer om 30 sekunder och går mot mästare {1}. msg.wave_on_master = Våg {0}: {1} hjältar marscherar mot mästare {2}. +msg.partner_out = Din AI-partners hjärta har fallit – kämpa vidare ensam! hud.rival1 = Grön hud.rival2 = Blå msg.creature_freed = En {0} bryter sina kedjor och ansluter sig till dig! diff --git a/src/main.rs b/src/main.rs index dc975b2..4c6dddb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -343,6 +343,16 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A menu.error = Some(i18n::tr("net.coop_editor_none")); } } + MenuAction::MatchCoopSolo(doc, mission) => { + // the open lobby connection is not needed: the game runs on this computer alone + if app.start_offline_coop(doc, mission, None, Vec::new()) { + menu.page = None; + menu.error = None; + app.set_status(i18n::tr("net.coop_solo_started"), now); + } else { + menu.error = Some(i18n::tr("net.coop_editor_none")); + } + } MenuAction::MatchTakeSide(side) => app.client.take_side(side), MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai), MenuAction::MatchSetTeam(side, team, own) => app.client.set_team(if own { None } else { Some(side) }, team), @@ -500,6 +510,7 @@ struct App { coop: bool, coop_editor: bool, // the co-op match plays a mission of an editor campaign coop_run: Option, // ... and this is where the run through that campaign stands + offline: bool, // a co-op game played without the server: the other seat is an AI partner fps: f32, renderer: String, frame_ms: f32, @@ -624,9 +635,32 @@ impl App { self.match_mode = false; self.coop = false; self.coop_editor = false; + self.offline = false; self.client.on_disconnect(); } + // A co-op mission without the server: built like a match start with the player on side 0 and an + // AI partner on side 2, then the world runs on this computer alone (no turns, no relay). + // `doc` is an editor campaign (None: a built-in co-op mission); `run_seed`/`carry` continue a run. + fn start_offline_coop(&mut self, doc: Option, mission: usize, run_seed: Option, carry: Vec<(u8, campaign::Carry)>) -> bool { + let size = match &doc { + Some(d) => d.missions.get(mission).map(|m| m.size), + None => campaign::missions().get(mission).map(|m| m.size), + }; + let Some((map_w, map_h)) = size else { return false }; + let next = run_seed.is_some(); + let seed = run_seed.unwrap_or_else(|| next_seed(self.seed)); + let editor = doc.is_some(); + let info = StartInfo { seed, map_w, map_h, difficulty: self.settings.difficulty, sides: vec![0, 2], local_side: Some(0), mission: Some(mission), ai: vec![2], doc, editor, teams: Vec::new(), next, carry }; + if !self.start_match_game(info) { + return false; + } + self.game.end_match(); + self.offline = true; + self.recorded = false; + true + } + // the shared part of a co-op start (built-in or editor mission): the masters, AI seats, settings fn start_coop_game(&mut self, mut game: Game, info: &StartInfo) -> bool { let local = info.local_side.unwrap_or(0); @@ -758,7 +792,7 @@ async fn main() { let seed = (miniquad::date::now() * 1000.0) as u32 | 1; // behind the title menu: the endless bridge battle let (game, cam) = Game::new_demo(seed); - let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, coop_run: None, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None }; + let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, coop_run: None, offline: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None }; menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); apply_game_settings(&mut app.game, &app.settings); let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None }; @@ -1471,6 +1505,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i); | MenuAction::MatchAbort | MenuAction::MatchLeave | MenuAction::MatchCoopEditorCreate(..) + | MenuAction::MatchCoopSolo(..) | MenuAction::MatchChat(..) | MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now), MenuAction::EditName => { @@ -1517,13 +1552,41 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i); // a co-op mission of an editor campaign is over: the host starts the next one (or the // same one again) for everybody; the others wait for it UiAction::EndNext if app.coop_editor && app.game.local_side == 0 && app.coop_run.as_ref().map_or(false, |r| r.next.is_some() && !r.sent) => { - if let Some(run) = app.coop_run.as_mut() { + if app.offline { + // no relay: the next mission of the run starts here at once + let run = app.coop_run.take().expect("a co-op run"); + let next = run.next.unwrap_or(run.mission); + if app.start_offline_coop(Some(run.doc), next, Some(run.run_seed), run.carry_out) { + ui.log.clear(); + autosave_t = 0.0; + } + } else if let Some(run) = app.coop_run.as_mut() { if let Some(next) = run.next { app.client.next_mission(next, run.run_seed, &run.carry_out); run.sent = true; } } } + // a built-in co-op mission without the server: the next one after a victory, else again + UiAction::EndNext if app.offline && !app.coop_editor => { + let i = app.game.mission.as_ref().map_or(0, |m| m.index); + let next = if app.game.state == 1 { campaign::next_coop_mission(i, 1) } else { i }; + if next <= i && app.game.state == 1 { + app.leave_match(); + menu.page = Some(Page::Title); + app.seed = next_seed(app.seed); + (app.game, app.cam) = Game::new_demo(app.seed); + } else if app.start_offline_coop(None, next, None, Vec::new()) { + ui.log.clear(); + autosave_t = 0.0; + } + } + UiAction::EndCampaign if app.offline && !app.coop_editor => { + app.leave_match(); + menu.page = Some(Page::Title); + app.seed = next_seed(app.seed); + (app.game, app.cam) = Game::new_demo(app.seed); + } UiAction::EndNext if app.coop_editor && app.coop_run.as_ref().map_or(false, |r| r.next.is_some()) => {} // ... or the run is over (or the player leaves): the match ends, back to the lobby UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => { @@ -1755,6 +1818,8 @@ app.pending_briefing = briefing_of(&campaign, i); run.finish(&app.game); } } + } else if app.coop { + // a built-in co-op mission is no step of the single-player campaign: no progress } else if app.custom_campaign.is_none() { if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) { if app.game.state != 0 { diff --git a/src/menu.rs b/src/menu.rs index b010c03..f9b85c5 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -279,6 +279,7 @@ enum Btn { MatchCoopNext, MatchCoopCreate, MatchCoopSource, // switch between the built-in campaign and the editor campaign + MatchCoopSolo, // the selected co-op mission without the server, with an AI partner MatchTake(usize), MatchAi(usize), MatchTeam(usize), // next team for that seat @@ -397,6 +398,7 @@ pub enum MenuAction { MatchCreate, MatchCoopCreate(usize), MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc` + MatchCoopSolo(Option, usize), // offline with an AI partner: editor campaign or built-in, mission MatchTakeSide(u8), MatchSetAi(u8, bool), MatchSetTeam(u8, u8, bool), // side, team, the own seat @@ -798,6 +800,8 @@ impl Menu { self.confirm = None; MenuAction::Consumed } + Some(Btn::MatchCoopSolo) if self.coop_editor => MenuAction::MatchCoopSolo(Some(self.coop_doc().clone()), self.coop_editor_mission), + Some(Btn::MatchCoopSolo) => MenuAction::MatchCoopSolo(None, self.coop_mission), Some(Btn::MatchCoopCreate) if self.coop_editor => MenuAction::MatchCoopEditorCreate(self.coop_editor_mission), Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission), Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready), @@ -1992,6 +1996,7 @@ impl Menu { items.push((Btn::MatchCoopPrev, "<".to_string())); items.push((Btn::MatchCoopCreate, tr("net.coop_create"))); items.push((Btn::MatchCoopNext, ">".to_string())); + items.push((Btn::MatchCoopSolo, tr("net.coop_solo"))); } // the button names what comes next let next = match (editor_coop, self.coop_league) { diff --git a/src/rival.rs b/src/rival.rs index 2ecf08c..c8c7af1 100644 --- a/src/rival.rs +++ b/src/rival.rs @@ -7,6 +7,10 @@ use crate::map::*; use serde::{Deserialize, Serialize}; pub const RIVAL_CORE_HP: f32 = 2000.0; +// a co-op AI partner helps masters short of gold: every minute it may send some +pub const PARTNER_GIFT_EVERY: f32 = 60.0; +pub const PARTNER_GIFT_BELOW: f32 = 300.0; +pub const PARTNER_GIFT: f32 = 400.0; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Rival { @@ -20,6 +24,8 @@ pub struct Rival { pub attack_t: f32, pub difficulty: u8, // 0 easy, 1 medium, 2 hard pub power: f32, // grows with the time: level of the new creatures + #[serde(default)] + pub gift_t: f32, // co-op AI partner: seconds until it looks whether its allies need gold } struct Pace { @@ -135,7 +141,7 @@ impl Game { t.gold = 300; t.res = 0; } - self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: r, grow_t: 20.0, spawn_t: 5.0, attack_t: pace(difficulty).attack, difficulty, power: 0.0 }); + self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: r, grow_t: 20.0, spawn_t: 5.0, attack_t: pace(difficulty).attack, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY }); for k in 0..3 { self.spawn_rival_unit(side, EK::Kobold, 1, k as f32); } @@ -167,6 +173,14 @@ impl Game { r.spawn_t -= dt; r.attack_t -= dt; } + // a co-op partner sends gold to allied masters who run short + if self.is_partner(side) { + self.rivals[k].gift_t -= dt; + if self.rivals[k].gift_t <= 0.0 { + self.rivals[k].gift_t = PARTNER_GIFT_EVERY; + self.partner_gift(side); + } + } // growth: one more ring of ground (no imps needed) if self.rivals[k].grow_t <= 0.0 { self.rivals[k].grow_t = p.grow * (0.8 + self.rng.f() * 0.4); @@ -294,7 +308,14 @@ impl Game { let (x, y) = self.rivals[k].centre(); self.fx(1, x, y, 0.0, 0.0, 1.0, 0xffffffff); self.sfx(Sfx::Victory, x, y); - self.msg(if side == 2 { "msg.rival1_out" } else { "msg.rival2_out" }, vec![]); + let key = if self.is_partner(side) { + "msg.partner_out" + } else if side == 2 { + "msg.rival1_out" + } else { + "msg.rival2_out" + }; + self.msg(key, vec![]); // its creatures scatter and vanish, its ground is free for e in self.ents.iter_mut() { if e.alive && !e.hero && e.side == side { @@ -385,6 +406,41 @@ impl Game { } // units of a rival master: fight enemies nearby, march when sent, otherwise guard home + // an AI seat of a co-op game: an allied master who builds his own dungeon + pub fn is_partner(&self, side: u8) -> bool { + self.coop && self.ai_sides.contains(&side) + } + + // the partner gives gold to every allied master below the limit + fn partner_gift(&mut self, side: u8) { + let me = self.me_side; + for s in self.master_sides() { + if self.is_out(s) || self.gold_of(s) >= PARTNER_GIFT_BELOW { + continue; + } + self.switch_to(s); + let got = self.add_gold(PARTNER_GIFT).floor(); + if got >= 1.0 { + self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{side}")]); + self.sfx_ui(Sfx::Payday); + } + } + self.switch_to(me); + } + + // a co-op partner's creatures follow the attack flag of the first master still standing + fn partner_flag(&self, side: u8) -> Option { + if !self.is_partner(side) { + return None; + } + let lead = self.master_sides().into_iter().find(|s| !self.is_out(*s))?; + if lead == self.me_side { + self.flag + } else { + self.masters.iter().find(|m| m.side == lead)?.flag + } + } + pub fn rival_ai(&mut self, e: &mut Ent, dt: f32) { if e.think <= 0.0 { e.think = 0.5; @@ -397,18 +453,41 @@ impl Game { } } } + // two of three healthy creatures of a co-op partner join the attack at the allies' flag + let assist = if e.id % 3 != 0 && e.hp > e.max_hp * 0.4 { self.partner_flag(e.side) } else { None }; if let Job::Attack(id) = e.job { let (hx, hy) = e.home.unwrap_or((e.x, e.y)); - let far = e.target.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0; + let far = e.target.is_none() && assist.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0; if !far && self.attack_job(e, id, dt) { return; } e.job = Job::None; e.path.clear(); } + if let Some(f) = assist { + // the flag at the humans' gate: attack the gate + let (fx, fy) = xy(f); + let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5); + let (gx, gy) = self.gate_center(); + if e.target.is_none() && self.has_gate() && self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0 { + e.target = Some(1); + } + } if self.march_on(e, dt) { return; } + if let Some(f) = assist { + let (fx, fy) = xy(f); + let d2 = (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2); + if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 { + e.repath = 1.5; + if let Some(p) = self.map.creature_path(flies(e.kind), e.tile(), f) { + e.path = p; + } + } + self.step_path(e, dt); + return; + } // guard the own dungeon let (hx, hy) = e.home.unwrap_or((e.x, e.y)); let d2 = (e.x - hx).powi(2) + (e.y - hy).powi(2); @@ -538,4 +617,61 @@ mod tests { g.spawn_wave(); assert!(g.ents[before..].iter().filter(|e| e.hero).all(|e| e.target.is_none()), "the wave comes for the player"); } + + // the first mission of the co-op league, set up like an offline co-op game: player on side 0, + // an AI partner on side 2, no server + fn offline_league_game() -> Game { + let doc = crate::editor::coop_campaign::coop_campaign(); + let mut g = crate::campaign::start_editor_coop_mission(&doc.missions[0], 0, 77); + g.coop = true; + assert!(g.start_match(&[0, 2], 0)); + g.sync_ai_sides(&[2], 1); + g.end_match(); + g + } + + #[test] + fn an_offline_coop_partner_is_an_allied_ai_master() { + let mut g = offline_league_game(); + assert!(g.netplay.is_none() && g.masters.is_empty()); + assert!(g.rival_alive(2) && g.is_partner(2)); + assert!(g.allied(0, 2) && g.allied(2, 0)); + // short of gold: the partner helps out within a minute + g.gold = 0.0; + for _ in 0..(65 * 20) { + g.update(0.05); + } + assert!(g.msgs.iter().any(|m| m.key == "msg.gold_received"), "the partner sent gold"); + assert_eq!(g.state, 0); + // the partner's creatures never fight the player's + assert!(!g.ents.iter().any(|e| e.alive && !e.hero && e.side == 0 && e.hp < e.max_hp && matches!(e.job, Job::Attack(_)))); + } + + #[test] + fn the_partner_follows_the_attack_flag() { + let mut g = offline_league_game(); + for _ in 0..(90 * 20) { + g.update(0.05); + } + let (rx, ry) = g.rivals.iter().find(|r| r.side == 2).unwrap().centre(); + // a flag on the partner's own ground a little away from its core + let f = (0..g.map.t.len()) + .find(|&i| { + let (x, y) = xy(i); + let d2 = (x as f32 - rx).powi(2) + (y as f32 - ry).powi(2); + g.map.t[i].owner == Owner::of_side(2) && g.map.walk(x, y) && d2 > 16.0 && d2 < 36.0 + }) + .expect("partner ground"); + g.flag = Some(f); + let (fx, fy) = xy(f); + let mut near = false; + for _ in 0..(30 * 20) { + g.update(0.05); + near |= g.ents.iter().any(|e| e.alive && e.side == 2 && !e.hero && e.id % 3 != 0 && (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2) < 3.0 * 3.0); + if near { + break; + } + } + assert!(near, "partner creatures gather at the flag"); + } }