Compare commits

..
2 Commits
18 changed files with 1937 additions and 57 deletions

No files matched your search

+6 -2
View File
@@ -117,8 +117,12 @@ Ctrl+Z/Y undo/redo, Ctrl+C/V copy/paste the selected block, Delete removes it, C
save; *Published* adds the campaign to the list every logged-in player sees and can open (as a save; *Published* adds the campaign to the list every logged-in player sees and can open (as a
copy) and play. Server API: `/api/campaigns`, `/api/public/campaigns` (see `server/src/main.rs`). copy) and play. Server API: `/api/campaigns`, `/api/public/campaigns` (see `server/src/main.rs`).
- **Co-op**: the campaign last opened in the editor can be played in a co-op match (Multiplayer, - **Co-op**: the campaign last opened in the editor can be played in a co-op match (Multiplayer,
*Editor campaign*), one mission per match; missions need a map of at least 72x72 and a goal besides *Editor campaign*); missions need a map of at least 72x72 and a goal besides rival masters. The run
rival masters. See `docs/MULTIPLAYER.md`. goes on over several missions: after a win the host starts the next mission (the branch of the reached
goal, or *next mission*) for everybody, after a defeat the same one again. Every player takes his five
strongest creatures and a fifth of his gold along; the host keeps the progress (missions done, chosen
branches, run seed). The room list shows the campaign's name and the current mission. See
`docs/MULTIPLAYER.md`.
## File format (`src/campaign_doc.rs`) ## File format (`src/campaign_doc.rs`)
+35 -6
View File
@@ -76,8 +76,37 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
message); the relay keeps it in the room and adds it to the `start` message (room updates only show message); the relay keeps it in the room and adds it to the `start` message (room updates only show
`"editor": true`). Every client builds the mission with `campaign::start_editor_coop_mission(doc, i, seed)` from `"editor": true`). Every client builds the mission with `campaign::start_editor_coop_mission(doc, i, seed)` from
the room's shared seed (rival masters of the map and the Rivals goal are left out, like in built-in co-op), the room's shared seed (rival masters of the map and the Rivals goal are left out, like in built-in co-op),
then the usual co-op setup. Block scripts run from side 0 in the fixed step. When the mission ends, the match then the usual co-op setup. Block scripts run from side 0 in the fixed step.
ends and the players go back to the lobby; there is no branching to a next mission and no progress is stored. - **Editor campaign runs over several missions**: when a mission ends, the host's end dialog offers *Next mission*
(won: the branch of the reached goal, `CampaignDoc::next_mission_choice` with `Game::match_goal_done`) or *Try
again* (lost: the same mission); the others see "waiting for the host" and may leave. The host sends
`{"t":"next","mission":i,"seed":run_seed,"carry":[[side,Carry],..]}`; the relay checks that he is side 0 and that
the mission exists, resets the turn clock, drops pending orders and sends `next` (seed, sides, teams, AI seats,
carry, no campaign) to every client. The clients keep the campaign of the `start`, drop turns of the old mission
and build the new one with `start_editor_coop_mission(doc, i, run_seed)`, then `campaign::apply_coop_carry`.
The carry (`campaign::coop_carry_from`, computed by the host from the shared final world) is per master: his
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.
- **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
`sides` (`Game::set_teams`). Masters of one team are allies exactly like co-op partners (`Game::allied`: no
attacks, their creatures help each other, a tower spares them, the fog shows the team mates' ground, creatures
and towers). AI seats take part in teams as well.
- **Fallen hearts**: a master whose soul core falls is out (`Game::out_sides`, shared state, in the checksum). His
creatures fight on without a heart (no economy: no mana, no new creatures, no payday), the creatures in his hand
are put down, the core tiles become floor and his orders are ignored. The player watches: no fog, free camera,
chat; the match goes on for the others (`Game::spectating`, local only).
- **End of a match**: decided only from shared state (`Game::check_match_end`), so every client stops in the same
step: a versus match is over when only one team has masters (or AI seats) left - that team wins, the others lose;
if all seats are one team, the humans' gate is the enemy. A mission (co-op) is lost when every heart fell, and
won by its goals (mana springs count for any master). The checksum only mixes in *whether* the world still runs.
- **Gold for allies**: `Act::SendGold { to, amount }` (100, 500, 1000 or all), in the creature overview and the
pause menu (the ally button switches between allies). It runs in the fixed step on every client, never sends
more than the sender has nor more than the receiver's treasury takes; the receiver gets a message and a sound.
- **Room list**: rooms show what they play - versus, the built-in co-op mission, or the editor campaign's name and
the title of its current mission (`campaign`, `title` in `rooms` and `room`).
- **Shared simulation side**: the shared part of a step (waves, units, towers, rivals, dwarves) runs with the - **Shared simulation side**: the shared part of a step (waves, units, towers, rivals, dwarves) runs with the
first master switched in on every client; only the end check and the fog use the local master. first master switched in on every client; only the end check and the fog use the local master.
@@ -89,12 +118,12 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
| B | All player actions as commands (`Command` + `Game::apply`), single player uses the same path; fixed time step | built | | B | All player actions as commands (`Command` + `Game::apply`), single player uses the same path; fixed time step | built |
| C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat, ping | built | | C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat, ping | built |
| D | Client: *Multiplayer* menu (lobby, create, join, seats, ready), WebSocket bridge in `dc_plugin.js`, chat, ping | built | | D | Client: *Multiplayer* menu (lobby, create, join, seats, ready), WebSocket bridge in `dc_plugin.js`, chat, ping | built |
| E | Alliances and co-op rules, AI masters for free and abandoned seats | partly built: co-op alliance and AI seats work; free teams in versus and sending gold are missing | | E | Alliances and co-op rules, AI masters for free and abandoned seats | built: co-op alliance, teams A-D in versus, sending gold, fallen hearts as spectators, AI seats |
| F | Co-op campaign: 20 missions with story (EN/DE) | partly built: co-op matches reuse the single player missions that fit the master areas (`coop_mission_indexes`); no own co-op missions or story yet | | F | Co-op campaign: 20 missions with story (EN/DE) | partly built: co-op matches reuse the single player missions that fit the master areas (`coop_mission_indexes`); no own co-op missions or story yet |
Still open: teams/alliances in versus, sending gold to allies, own co-op campaign missions with story; still to be | G | Editor campaigns in co-op over several missions (branches, carry, host progress), campaign names in the room list | built |
checked against the decisions above: a fallen heart only knocking out that player (who may keep watching), and how
savegames treat a running match. Still open: own co-op campaign missions with story; how savegames treat a running match.
## Masters and sides ## Masters and sides
+144
View File
@@ -1338,3 +1338,147 @@ blk.w_seconds = Sekunden
blk.help.until = Führt die Blöcke darin immer wieder aus, bis die Bedingung gilt - geprüft vor jedem Durchlauf; gilt sie schon, läuft der Inhalt gar nicht. blk.help.until = Führt die Blöcke darin immer wieder aus, bis die Bedingung gilt - geprüft vor jedem Durchlauf; gilt sie schon, läuft der Inhalt gar nicht.
blk.help.dountil = Führt die Blöcke darin aus, prüft dann die Bedingung und wiederholt, bis sie gilt - der Inhalt läuft mindestens einmal. blk.help.dountil = Führt die Blöcke darin aus, prüft dann die Bedingung und wiederholt, bis sie gilt - der Inhalt läuft mindestens einmal.
blk.help.wait = Hält dieses Skript einige Sekunden an, danach geht es mit dem nächsten Block weiter (auch in Schleifen). blk.help.wait = Hält dieses Skript einige Sekunden an, danach geht es mit dem nächsten Block weiter (auch in Schleifen).
msg.master_out = {0} hat den Seelenkern verloren - die Kreaturen kämpfen ohne Herz weiter.
msg.master_out_you = Dein Seelenkern ist zerbrochen. Du bist ausgeschieden - sieh dem Rest der Partie zu.
msg.match_won = Dein Team hat die Partie gewonnen!
msg.match_lost = Die Partie ist verloren - ein anderes Team hat gewonnen.
msg.gold_received = {1} schickt dir {0} Gold.
msg.gold_sent = {0} Gold an {1} geschickt.
msg.gold_none = Du hast kein Gold zum Verschicken.
msg.gold_full = Die Schatzkammer von {0} ist voll.
gold.send_to = Gold an {0}
gold.all = Alles
net.spectating = Dein Herz ist gefallen - du schaust der Partie zu (Chat mit Enter).
net.team = Team {0}
net.versus = Gegeneinander
net.coop_wait_host = Warte, bis der Gastgeber die nächste Mission startet...
net.coop_run_over = Zurück in die Lobby
# ---- co-op campaign "League against the Lightwatch"
coop.name = Bund gegen die Lichtwacht
coop.raid = Mossgrims Kreaturen kriechen aus der Dunkelheit!
coop.m01.title = Zwei Herzen, ein Krummfinger
coop.m01.story = Meister, Mehrzahl. Ich konnte es auch kaum glauben. Der Orden der Lichtwacht hat einen Pakt mit Mossgrim geschlossen, diesem moosigen Überläufer, um jeden Kerker unter den Hügeln auszuräuchern.\n\nAllein würdet ihr geräuchert. Zusammen seid ihr vielleicht der Rauch. Grabt euch Seite an Seite ein und zeigt der Patrouille nebenan, was ein gemeinsamer Keller bedeutet.
coop.m01.start = Zwei Meister, ein Berater. Mein Lohn bleibt übrigens gleich.
coop.m01.goal = Besiegt alle Ritter.
coop.m01.t1 = Teilt euch die Arbeit: einer gräbt, einer baut. Oder beide graben. Ich bin flexibel.
coop.m01.t2 = Die Patrouille wird neugierig. Gut. Neugier ist ein feiner Happen.
coop.m02.title = Der erste Posten
coop.m02.story = Jenseits des Flusses hat die Lichtwacht einen Posten mit glänzenden Türmen gebaut. Glänzendes muss kaputt - das ist die älteste Regel der Unterwelt.\n\nÜberbrückt das Wasser, reißt die Türme ein, und das gemeinsam, bevor sie ihre Freunde rufen.
coop.m02.start = Türme. Poliert, spitz, beleidigend. Das richten wir.
coop.m02.goal = Schleift die Rittertürme.
coop.m02.t1 = Brücken sind wie Freundschaften, Meister: erst bauen, dann eine Armee drüberschicken.
coop.m02.t2 = Ein Turm weg ist ein Anfang. Alle Türme weg ist ein Fest.
coop.m03.title = Spuren im Moos
coop.m03.story = Etwas Grünes, Nasses lauert in den Tunneln. Mossgrim hat den Rittern seine Kreaturen geliehen, und sie kommen in Wellen durchs Heldentor.\n\nHaltet durch. Haltet zusammen. Haltet euch die Nase zu.
coop.m03.start = Mossgrims Oger riechen nach nassem Teppich. Ihr merkt es, wenn sie kommen.
coop.m03.goal = Übersteht drei Wellen.
coop.m03.t1 = Die ersten Oger sind da. Mossgrim konnte seine Haustiere noch nie zu Hause halten.
coop.m03.t2 = Haltet eure Herzen auseinander, Meister - kein Oger sollte beide erreichen.
coop.m04.title = Die gestohlenen Quellen
coop.m04.story = Zwei Manaquellen sprudeln auf Inseln im See, und die Ritter haben sie zu ihrem Weihwasser erklärt. Weihwasser, also wirklich.\n\nNehmt beide Quellen. Und fällt euch vorher der Tresor der Ritter in die Hände, kennt Krummfinger eine Abkürzung.
coop.m04.start = Mana schmeckt besser, wenn es vorher jemand anderem gehört hat.
coop.m04.goal = Nehmt zwei Manaquellen.
coop.m04.branch = Reich genug für die Abkürzung
coop.m04.t1 = Inseln brauchen Brücken. Oder Flügel. Fledermäuse sind Flügel mit Zähnen.
coop.m04.t2 = Der Tresor sieht schwer aus. Schweres sollte man nach Hause tragen.
coop.m05.title = Ketten im Fels
coop.m05.story = Die Lichtwacht hält Kreaturen in Ketten, in versiegelten Höhlen - Kreaturen, die sich nicht beugen wollten. Ein Oger und ein Magier, beide wütend, beide zu haben.\n\nGrabt zu ihnen, erobert ihre Höhlen, und die Ketten fallen.
coop.m05.start = Gefangene! Bald unsere. Befreite, meine ich. Frei, für euch zu arbeiten.
coop.m05.goal = Befreit zwei angekettete Kreaturen.
coop.m05.t1 = Hört ihr den Oger fluchen? Das ist Dankbarkeit im Voraus.
coop.m05.t2 = Wieder Spinnen aus dem Moos. Mossgrims Kinderstube hat Hochbetrieb.
coop.m06.title = Brücke der Wacht
coop.m06.story = Zwei Flüsse, eine Festung und die besten Schützen der Lichtwacht auf den Mauern. Sie nennen es die Brücke der Wacht. Wir nennen es die Brücke, die mal war.\n\nBrecht ihre Türme. Lasst die Brücke stehen - wir brauchen sie vielleicht.
coop.m06.start = Schützen auf Mauern sind wie Tauben auf Dächern: laut und leicht zu entfernen.
coop.m06.goal = Schleift die Rittertürme.
coop.m06.t1 = Zwei Flüsse heißt zwei Brücken. Oder eine sehr nasse Armee.
coop.m06.t2 = Die Ritter polieren ihre Rüstungen. Geben wir ihnen einen Grund.
coop.m07.title = Die Lavaschmiede
coop.m07.story = Zwischen zwei Lavaflüssen schmieden die Ritter ihre gesegneten Klingen. Ihr Tresor quillt über vor Gold, bezahlt von jedem Kerker, den sie geleert haben.\n\nWir holen es uns zurück. Alles. Und ein bisschen mehr.
coop.m07.start = Warme Füße, kalte Herzen, volle Taschen: mein Abend.
coop.m07.goal = Erbeutet Gold von den Rittern.
coop.m07.t1 = Nicht zu nah an die Lava. Eure Kreaturen, meine ich. Ihr seid sicher.
coop.m07.t2 = Mehr Ritter aus dem Osten. Die lieben ihr Gold wirklich.
coop.m08.title = Das Lazarett
coop.m08.story = Die Lichtwacht hat in den Höhlen ein Lazarett errichtet, und ihre Priester flicken jeden Ritter, den ihr umhaut.\n\nBeendet die Behandlung. Endgültig.
coop.m08.start = Priester. Sie heilen. Wir tun das Gegenteil. Eine ausgeglichene Beziehung.
coop.m08.goal = Besiegt alle Ritter.
coop.m08.t1 = Jede Minute kommt ein neuer Priester. Schneller, Meister.
coop.m08.t2 = Ein Kerker ohne Arztrechnungen. Das nenne ich wirtschaftlich.
coop.m09.title = Mossgrims Botschafter
coop.m09.story = Mossgrim hat den Rittern einen Botschafter geschickt: einen Paladin in grünfleckiger Rüstung, der die Versprechen seines Herrn überbringt.\n\nVersprechen sollten nie ankommen. Findet den Botschafter und bringt ihn zum Schweigen.
coop.m09.start = Ein Botschafter. Ein Bote mit schickem Hut. Hüte fallen.
coop.m09.goal = Besiegt Mossgrims Botschafter.
coop.m09.boss = Der Botschafter ist gefallen. Seine Botschaft: unzustellbar.
coop.m09.t1 = Der Botschafter wartet im Osten, umgeben von Ogern ohne Humor.
coop.m09.t2 = Irgendwo schreibt Mossgrim schon den nächsten Brief. Machen wir ihn kurz.
coop.m10.title = Die Zwillingstürme
coop.m10.story = Zwei Festungen bewachen den Weg nach Norden, jede deckt der anderen den Rücken. Greift eine an, antwortet die andere.\n\nGreift beide an. Gleichzeitig. Dafür gibt es zwei Meister.
coop.m10.start = Zwei Festungen, zwei Meister. Gerecht ist gerecht.
coop.m10.goal = Schleift die Rittertürme.
coop.m10.t1 = Teilt euch auf, Meister - jedem eine Festung. Ich jubele für beide.
coop.m10.t2 = Fallen beide zugleich, wissen die Ritter nicht einmal, wem sie die Schuld geben sollen.
coop.m11.title = Die Belagerung
coop.m11.story = Die Lichtwacht hat eure Herzen gefunden und bringt alles: fünf Wellen Ritter und Mossgrims Wasserspeier von den Klippen.\n\nDiesmal keine Raffinesse. Nur Mauern, Fallen, Türme - und einander.
coop.m11.start = Sie kommen. Alle. Na ja, die meisten.
coop.m11.goal = Übersteht fünf Wellen.
coop.m11.t1 = Türen und Fallen, Meister. Eine Belagerung sind nur Gäste, die nicht klingeln wollen.
coop.m11.t2 = Wasserspeier aus dem Süden. Stein mit Flügeln - das hat niemand bestellt.
coop.m12.title = Das Tor
coop.m12.story = Durch das Heldentor dieser Höhle erreichen die Ritter die tiefen Kerker. Schließt es, und ihr Nachschub muss den langen Weg nehmen.\n\nBrecht das Tor, und die Lichtwacht spürt es bis hinauf zum Hochmarschall.
coop.m12.start = Ein Tor. Tore sind nur Mauern mit Ambitionen.
coop.m12.goal = Zerstört das Heldentor.
coop.m12.t1 = Es kommen immer neue Wellen heraus. Lästig, wie ein tropfender Hahn.
coop.m12.t2 = Drückt, Meister! Das Tor bekommt Risse. Meine Geduld auch.
coop.m13.title = Moosige Tunnel
coop.m13.story = Wir sind jetzt in Mossgrims Hinterhof. Seine Tunnel sind weich und feucht, und zwei seiner Horte liegen in versiegelten Höhlen.\n\nGraben, nehmen, gehen. Mossgrim wird es merken. Genau darum geht es.
coop.m13.start = Es riecht nach Pilzen und Verrat. Mossgrim muss nah sein.
coop.m13.goal = Erbeutet Gold.
coop.m13.t1 = Spinnen, schon wieder. Mossgrim züchtet sie wie andere Leute Tomaten.
coop.m13.t2 = Seine Horte sind größer als gedacht. Unsere Taschen sind noch größer.
coop.m14.title = Gift im Wasser
coop.m14.story = Mossgrim hat seine Toten zu Wiedergängern gemacht und schickt sie von zwei Seiten. Die Ritter nennen das heiliges Werk. Typisch.\n\nBesiegt jeden Ritter und jede wandelnde Leiche. Danach Hände waschen.
coop.m14.start = Wiedergänger. Unser Trick, geklaut. Ich bin gekränkt.
coop.m14.goal = Besiegt alle Feinde.
coop.m14.t1 = Sie kommen von Norden und Süden. Rücken an Rücken, Meister.
coop.m14.t2 = Ein Toter, der läuft, ist ein Toter, der seine Arbeit noch nicht erledigt hat.
coop.m15.title = Der Kettenbrecher
coop.m15.story = Tief im Fels halten die Ritter den Kettenbrecher, eine Kreatur, die selbst sie fürchten. Befreit ihn, und er kämpft für den, der seine Zelle geöffnet hat.\n\nOder schnappt euch genug Gold für den Umweg - Krummfinger kennt einen Schmuggler.
coop.m15.start = Der Kettenbrecher. Groß, wütend, missverstanden. Ein bisschen wie ich.
coop.m15.goal = Befreit den Kettenbrecher und schleift die Türme.
coop.m15.branch = Die Karte des Schmugglers gekauft
coop.m15.t1 = Die Zelle liegt im Nordwesten. Vorsicht, er hat Temperament.
coop.m15.t2 = Der Schmuggler wartet. Schmuggler hassen Warten. Und lieben Gold.
coop.m16.title = Die brennende Quelle
coop.m16.story = Mitten in einem Lavasee liegt eine Insel mit einer Manaquelle, die nie versiegt. Mossgrims Magier bewachen sie.\n\nNehmt die Quelle, besiegt die Wächter. Dann setzt euch auf die Insel und genießt die Wärme.
coop.m16.start = Eine Quelle in der Lava. Wer sich das ausgedacht hat, hat Stil. Schade, dass es Mossgrim ist.
coop.m16.goal = Nehmt die Quelle und besiegt alle Feinde.
coop.m16.t1 = Magier aus dem Osten. Ihre Zauber sind grün und ihre Zähne auch.
coop.m16.t2 = Gehört die Quelle uns, gibt es Mana für alle. Schön teilen.
coop.m17.title = Mossgrims Garten
coop.m17.story = Hier ist es: Mossgrims Garten, eine Höhle voller Pilze und Kreaturen, die ihm treu sind. Oger im Osten, ein Glutwurm im Norden.\n\nZertrampelt den Garten. Jeden Pilz.
coop.m17.start = All die Pilze. Schade um den Besitzer.
coop.m17.goal = Besiegt alle Feinde.
coop.m17.t1 = Ein Glutwurm! Dafür muss Mossgrim jahrelang gespart haben.
coop.m17.t2 = Der Garten welkt. Gärtnern ist nicht eure Stärke, Meister. Das ist ein Kompliment.
coop.m18.title = Die letzte Festung
coop.m18.story = Drei Festungen bewachen die Treppe hinab zur Halle des Hochmarschalls. Sie sind das Letzte zwischen euch und dem Ende des Kreuzzugs der Lichtwacht.\n\nSchleift sie alle. Dann gehört die Treppe euch.
coop.m18.start = Drei Festungen. Das Beste kommt zum Schluss, wie man sieht.
coop.m18.goal = Schleift die Rittertürme.
coop.m18.t1 = Jeder Meister eine Festung, die dritte gemeinsam. Ich zähle mit.
coop.m18.t2 = Ich sehe die Treppe schon. Staubig. Hier kommt niemand freiwillig herunter.
coop.m19.title = Der Hochmarschall
coop.m19.story = Der Hochmarschall der Lichtwacht persönlich, in einer seit hundert Jahren polierten Rüstung. Seine Garde kommt aus Norden und Süden.\n\nBringt ihn zu Fall, und der Orden hat niemanden mehr, der Befehle gibt.
coop.m19.start = Der Hochmarschall. Davon habe ich geträumt. Vor allem von dem Teil, wo er umfällt.
coop.m19.goal = Besiegt den Hochmarschall.
coop.m19.boss = Der Hochmarschall ist gefallen! Seine Rüstung scheppert wie eine umgekippte Besteckschublade.
coop.m19.t1 = Er wartet im Osten. Lasst ihn warten. Erst die Garde.
coop.m19.t2 = Wieder Ritter und Schützen. Dem Orden gehen die Ideen aus.
coop.m20.title = Mossgrims Fall
coop.m20.story = Ohne den Hochmarschall steht Mossgrim allein. Er hat die Gestalt eines Glutwurms angenommen und jede Kreatur versammelt, die ihm noch gehorcht.\n\nBeendet den Verräter. Beendet seine Armee. Und dann, Meister - Tee.
coop.m20.start = Mossgrim, du alter Pilz. Der Bund lässt grüßen.
coop.m20.goal = Besiegt Mossgrim und alle seine Kreaturen.
coop.m20.boss = Mossgrim ist nicht mehr. Das Moos wächst nach. Er nicht.
coop.m20.t1 = Der Wurm im Osten ist Mossgrim. Ja, er hat sich gehen lassen.
coop.m20.t2 = Jede Kreatur, die ihr besiegt, kann diese Geschichte nicht mehr falsch erzählen.
+144
View File
@@ -1339,3 +1339,147 @@ blk.w_seconds = seconds
blk.help.until = Runs the blocks inside again and again until the condition holds - checked before each run, so if it already holds the inside does not run at all. blk.help.until = Runs the blocks inside again and again until the condition holds - checked before each run, so if it already holds the inside does not run at all.
blk.help.dountil = Runs the blocks inside, then checks the condition and repeats until it holds - the inside always runs at least once. blk.help.dountil = Runs the blocks inside, then checks the condition and repeats until it holds - the inside always runs at least once.
blk.help.wait = Pauses this script for some seconds, then it goes on with the next block (also inside loops). blk.help.wait = Pauses this script for some seconds, then it goes on with the next block (also inside loops).
msg.master_out = {0} has lost the soul core - the creatures fight on without a heart.
msg.master_out_you = Your soul core has shattered. You are out - watch the rest of the match.
msg.match_won = Your team has won the match!
msg.match_lost = The match is lost - another team has won.
msg.gold_received = {1} sends you {0} gold.
msg.gold_sent = {0} gold sent to {1}.
msg.gold_none = You have no gold to send.
msg.gold_full = The treasury of {0} is full.
gold.send_to = Gold to {0}
gold.all = All
net.spectating = Your heart has fallen - you are watching the match (chat with Enter).
net.team = Team {0}
net.versus = Versus
net.coop_wait_host = Waiting for the host to start the next mission...
net.coop_run_over = Back to the lobby
# ---- co-op campaign "League against the Lightwatch"
coop.name = League against the Lightwatch
coop.raid = Mossgrim's creatures crawl out of the dark!
coop.m01.title = Two Hearts, One Crookfinger
coop.m01.story = Masters, plural. I could hardly believe it either. The Order of the Lightwatch has made a pact with Mossgrim, that mossy turncoat, to smoke out every dungeon under the hills.\n\nAlone, you would be smoked. Together you might just be the smoke. Dig in side by side and show the patrol nearby what a shared basement means.
coop.m01.start = Two masters, one advisor. My wages, by the way, stay the same.
coop.m01.goal = Defeat every knight.
coop.m01.t1 = Share the work: one digs, one builds. Or both dig. I am flexible.
coop.m01.t2 = The patrol is getting curious. Good. Curiosity is a lovely snack.
coop.m02.title = The First Outpost
coop.m02.story = Across the river the Lightwatch has built an outpost with shiny towers. Shiny things must be broken - that is the oldest rule of the underworld.\n\nBridge the water, bring the towers down, and do it together before they call their friends.
coop.m02.start = Towers. Polished, pointy, insulting. Let us fix that.
coop.m02.goal = Raze the knight towers.
coop.m02.t1 = Bridges are like friendships, masters: build them, then march an army over them.
coop.m02.t2 = One tower down is a start. All towers down is a party.
coop.m03.title = Tracks in the Moss
coop.m03.story = Something green and wet is lurking in the tunnels. Mossgrim has lent the knights his creatures, and they come through the hero gate in waves.\n\nHold out. Hold together. Hold your noses.
coop.m03.start = Mossgrim's ogres smell of wet carpet. You will know when they come.
coop.m03.goal = Survive three waves.
coop.m03.t1 = The first ogres are here. Mossgrim never could keep his pets at home.
coop.m03.t2 = Keep your hearts apart, masters - one ogre should never reach both of them.
coop.m04.title = The Stolen Springs
coop.m04.story = Two mana springs bubble on islands in the lake, and the knights have claimed them as their holy water. Holy water, honestly.\n\nTake both springs. And if the knights' vault happens to fall into your hands first, Crookfinger knows a shortcut.
coop.m04.start = Mana tastes better when it was someone else's.
coop.m04.goal = Claim two mana springs.
coop.m04.branch = Rich enough for the shortcut
coop.m04.t1 = Islands need bridges. Or wings. Bats are wings with teeth.
coop.m04.t2 = That vault looks heavy. Heavy things should be carried home.
coop.m05.title = Chains in the Rock
coop.m05.story = The Lightwatch keeps creatures in chains in sealed caves - creatures that refused to bow. An ogre and a mage, both furious, both available.\n\nDig to them, claim their caves, and the chains will fall.
coop.m05.start = Prisoners! Ours, soon. Free ones, I mean. Free to work for you.
coop.m05.goal = Free two chained creatures.
coop.m05.t1 = Listen. Do you hear the ogre swearing? That is gratitude in advance.
coop.m05.t2 = Spiders from the moss again. Mossgrim's nursery is busy.
coop.m06.title = Bridge of the Watch
coop.m06.story = Two rivers, one fortress, and the Lightwatch's finest archers on the walls. They call it the Bridge of the Watch. We will call it the bridge that was.\n\nBreak their towers. Leave the bridge - we might need it.
coop.m06.start = Archers on walls are like pigeons on roofs: loud and easy to remove.
coop.m06.goal = Raze the knight towers.
coop.m06.t1 = Two rivers mean two bridges. Or one very wet army.
coop.m06.t2 = The knights are polishing their armour. Let us give them a reason.
coop.m07.title = The Lava Smithy
coop.m07.story = Between two rivers of lava the knights forge their blessed blades. Their vault overflows with gold paid for by every dungeon they emptied.\n\nWe are taking it back. All of it. And then some.
coop.m07.start = Warm feet, cold hearts, full pockets: my kind of evening.
coop.m07.goal = Loot gold from the knights.
coop.m07.t1 = Do not stand too close to the lava. Your creatures, I mean. You are fine.
coop.m07.t2 = More knights from the east. They really love their gold.
coop.m08.title = The Field Hospital
coop.m08.story = The Lightwatch has set up a field hospital in the caves, and its priests patch up every knight you knock down.\n\nEnd the treatment. Permanently.
coop.m08.start = Priests. They heal. We do the opposite. It is a balanced relationship.
coop.m08.goal = Defeat every knight.
coop.m08.t1 = Every minute a new priest arrives. Kill faster, masters.
coop.m08.t2 = A dungeon without hospital bills. That is what I call efficient.
coop.m09.title = Mossgrim's Ambassador
coop.m09.story = Mossgrim has sent an ambassador to the knights: a paladin in green-stained armour who carries his master's promises.\n\nPromises should never arrive. Find the ambassador and silence him.
coop.m09.start = An ambassador. A messenger with a fancy hat. Hats come off.
coop.m09.goal = Defeat Mossgrim's ambassador.
coop.m09.boss = The ambassador has fallen. His message: undelivered.
coop.m09.t1 = The ambassador waits in the east, surrounded by ogres who lost their sense of humour.
coop.m09.t2 = Somewhere Mossgrim is sending another letter. Let us make it a short one.
coop.m10.title = The Twin Towers
coop.m10.story = Two fortresses guard the way north, each watching the other's back. Strike one and the other answers.\n\nStrike both. At once. That is what two masters are for.
coop.m10.start = Two fortresses, two masters. Fair is fair.
coop.m10.goal = Raze the knight towers.
coop.m10.t1 = Split up, masters - one fortress each. I will cheer for both.
coop.m10.t2 = When both fall at once, the knights will not even know whom to blame.
coop.m11.title = The Siege
coop.m11.story = The Lightwatch has found your hearts and brings everything: five waves of knights and Mossgrim's gargoyles from the cliffs.\n\nNo cleverness this time. Just walls, traps, towers - and each other.
coop.m11.start = They are coming. All of them. Well, most of them.
coop.m11.goal = Survive five waves.
coop.m11.t1 = Doors and traps, masters. A siege is just guests who refuse to use the bell.
coop.m11.t2 = Gargoyles from the south. Stone with wings - nobody asked for that.
coop.m12.title = The Gate
coop.m12.story = The hero gate in this cavern is how the knights reach the deep dungeons. Close it and their reinforcements must walk the long way round.\n\nBreak the gate, and the Lightwatch will feel it all the way up to the High Marshal.
coop.m12.start = A gate. Gates are just walls with ambitions.
coop.m12.goal = Destroy the hero gate.
coop.m12.t1 = Waves keep coming out of it. Annoying, like a dripping tap.
coop.m12.t2 = Push, masters! The gate is cracking. So is my patience.
coop.m13.title = Mossy Tunnels
coop.m13.story = We are in Mossgrim's backyard now. His tunnels are soft and damp, and two of his hoards lie in sealed caves.\n\nDig, take, leave. Mossgrim will notice. That is the point.
coop.m13.start = It smells of mushrooms and treachery. Mossgrim must be near.
coop.m13.goal = Loot gold.
coop.m13.t1 = Spiders, again. Mossgrim breeds them like other people grow tomatoes.
coop.m13.t2 = His hoards are bigger than I thought. Our pockets are bigger still.
coop.m14.title = Poison in the Water
coop.m14.story = Mossgrim has turned his dead into revenants and sends them from two sides. The knights call it holy work. Typical.\n\nDefeat every knight and every walking corpse. Then wash your hands.
coop.m14.start = Revenants. Our trick, stolen. I am offended.
coop.m14.goal = Defeat every enemy.
coop.m14.t1 = They come from the north and the south. Back to back, masters.
coop.m14.t2 = A dead thing that walks is a dead thing that has not finished its job yet.
coop.m15.title = The Chainbreaker
coop.m15.story = Deep in the rock the knights keep the Chainbreaker, a creature even they fear. Free it, and it will fight for whoever opened its cell.\n\nOr grab enough gold for the long way round - Crookfinger knows a smuggler.
coop.m15.start = The Chainbreaker. Big, angry, misunderstood. A bit like me.
coop.m15.goal = Free the Chainbreaker and raze the towers.
coop.m15.branch = Bought the smuggler's map
coop.m15.t1 = The cell is in the north-west. Mind its temper.
coop.m15.t2 = The smuggler is waiting. Smugglers hate waiting. And love gold.
coop.m16.title = The Burning Spring
coop.m16.story = In the middle of a lava lake lies an island with a mana spring that never runs dry. Mossgrim's mages guard it.\n\nTake the spring, defeat the guards. Then sit on the island and enjoy the warmth.
coop.m16.start = A spring in lava. Whoever thought of that has style. Shame it is Mossgrim.
coop.m16.goal = Claim the spring and defeat every enemy.
coop.m16.t1 = Mages from the east. Their spells are green and so are their teeth.
coop.m16.t2 = Once the spring is ours, mana for everyone. Share nicely.
coop.m17.title = Mossgrim's Garden
coop.m17.story = This is it: Mossgrim's garden, a cavern of mushrooms and creatures loyal to him. Ogres in the east, an ember wyrm in the north.\n\nTrample the garden. Every mushroom.
coop.m17.start = Look at all those mushrooms. Pity about the owner.
coop.m17.goal = Defeat every enemy.
coop.m17.t1 = An ember wyrm! Mossgrim must have saved up for years.
coop.m17.t2 = The garden is wilting. Gardening is not your strength, masters. That is a compliment.
coop.m18.title = The Last Fortress
coop.m18.story = Three fortresses guard the stairs down to the High Marshal's hall. They are the last thing between you and the end of the Lightwatch's crusade.\n\nRaze them all. Then the stairs are yours.
coop.m18.start = Three fortresses. They really did save the best for last.
coop.m18.goal = Raze the knight towers.
coop.m18.t1 = Each master a fortress, and the third one together. I will count.
coop.m18.t2 = I can see the stairs already. Dusty. Nobody comes down here voluntarily.
coop.m19.title = The High Marshal
coop.m19.story = The High Marshal of the Lightwatch himself, in armour polished for a hundred years. His guards come from north and south.\n\nBring him down, and the Order will have nobody left to give the orders.
coop.m19.start = The High Marshal. I have dreamed of this. Mostly the part where he falls over.
coop.m19.goal = Defeat the High Marshal.
coop.m19.boss = The High Marshal has fallen! His armour clatters like a dropped cutlery drawer.
coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first.
coop.m19.t2 = Knights and archers again. The Order is running out of ideas.
coop.m20.title = Mossgrim's Fall
coop.m20.story = With the High Marshal gone, Mossgrim stands alone. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nEnd the traitor. End his army. And then, masters - tea.
coop.m20.start = Mossgrim, you old fungus. The league sends its regards.
coop.m20.goal = Defeat Mossgrim and all his creatures.
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not.
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go.
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong.
+144
View File
@@ -1339,3 +1339,147 @@ blk.w_seconds = segundos
blk.help.until = Ejecuta los bloques de dentro una y otra vez hasta que se cumpla la condición; se comprueba antes de cada vuelta, así que si ya se cumple no se ejecuta nada. blk.help.until = Ejecuta los bloques de dentro una y otra vez hasta que se cumpla la condición; se comprueba antes de cada vuelta, así que si ya se cumple no se ejecuta nada.
blk.help.dountil = Ejecuta los bloques de dentro, luego comprueba la condición y repite hasta que se cumpla; el contenido se ejecuta al menos una vez. blk.help.dountil = Ejecuta los bloques de dentro, luego comprueba la condición y repite hasta que se cumpla; el contenido se ejecuta al menos una vez.
blk.help.wait = Detiene este script unos segundos y luego sigue con el siguiente bloque (también dentro de bucles). blk.help.wait = Detiene este script unos segundos y luego sigue con el siguiente bloque (también dentro de bucles).
msg.master_out = {0} ha perdido el núcleo de almas: sus criaturas siguen luchando sin corazón.
msg.master_out_you = Tu núcleo de almas se ha roto. Estás fuera: observa el resto de la partida.
msg.match_won = ¡Tu equipo ha ganado la partida!
msg.match_lost = La partida está perdida: otro equipo ha ganado.
msg.gold_received = {1} te envía {0} de oro.
msg.gold_sent = {0} de oro enviado a {1}.
msg.gold_none = No tienes oro para enviar.
msg.gold_full = El tesoro de {0} está lleno.
gold.send_to = Oro para {0}
gold.all = Todo
net.spectating = Tu corazón ha caído: estás observando la partida (chat con Intro).
net.team = Equipo {0}
net.versus = Todos contra todos
net.coop_wait_host = Esperando a que el anfitrión inicie la siguiente misión...
net.coop_run_over = Volver al vestíbulo
# ---- co-op campaign "League against the Lightwatch"
coop.name = League against the Lightwatch
coop.raid = Mossgrim's creatures crawl out of the dark!
coop.m01.title = Two Hearts, One Crookfinger
coop.m01.story = Masters, plural. I could hardly believe it either. The Order of the Lightwatch has made a pact with Mossgrim, that mossy turncoat, to smoke out every dungeon under the hills.\n\nAlone, you would be smoked. Together you might just be the smoke. Dig in side by side and show the patrol nearby what a shared basement means.
coop.m01.start = Two masters, one advisor. My wages, by the way, stay the same.
coop.m01.goal = Defeat every knight.
coop.m01.t1 = Share the work: one digs, one builds. Or both dig. I am flexible.
coop.m01.t2 = The patrol is getting curious. Good. Curiosity is a lovely snack.
coop.m02.title = The First Outpost
coop.m02.story = Across the river the Lightwatch has built an outpost with shiny towers. Shiny things must be broken - that is the oldest rule of the underworld.\n\nBridge the water, bring the towers down, and do it together before they call their friends.
coop.m02.start = Towers. Polished, pointy, insulting. Let us fix that.
coop.m02.goal = Raze the knight towers.
coop.m02.t1 = Bridges are like friendships, masters: build them, then march an army over them.
coop.m02.t2 = One tower down is a start. All towers down is a party.
coop.m03.title = Tracks in the Moss
coop.m03.story = Something green and wet is lurking in the tunnels. Mossgrim has lent the knights his creatures, and they come through the hero gate in waves.\n\nHold out. Hold together. Hold your noses.
coop.m03.start = Mossgrim's ogres smell of wet carpet. You will know when they come.
coop.m03.goal = Survive three waves.
coop.m03.t1 = The first ogres are here. Mossgrim never could keep his pets at home.
coop.m03.t2 = Keep your hearts apart, masters - one ogre should never reach both of them.
coop.m04.title = The Stolen Springs
coop.m04.story = Two mana springs bubble on islands in the lake, and the knights have claimed them as their holy water. Holy water, honestly.\n\nTake both springs. And if the knights' vault happens to fall into your hands first, Crookfinger knows a shortcut.
coop.m04.start = Mana tastes better when it was someone else's.
coop.m04.goal = Claim two mana springs.
coop.m04.branch = Rich enough for the shortcut
coop.m04.t1 = Islands need bridges. Or wings. Bats are wings with teeth.
coop.m04.t2 = That vault looks heavy. Heavy things should be carried home.
coop.m05.title = Chains in the Rock
coop.m05.story = The Lightwatch keeps creatures in chains in sealed caves - creatures that refused to bow. An ogre and a mage, both furious, both available.\n\nDig to them, claim their caves, and the chains will fall.
coop.m05.start = Prisoners! Ours, soon. Free ones, I mean. Free to work for you.
coop.m05.goal = Free two chained creatures.
coop.m05.t1 = Listen. Do you hear the ogre swearing? That is gratitude in advance.
coop.m05.t2 = Spiders from the moss again. Mossgrim's nursery is busy.
coop.m06.title = Bridge of the Watch
coop.m06.story = Two rivers, one fortress, and the Lightwatch's finest archers on the walls. They call it the Bridge of the Watch. We will call it the bridge that was.\n\nBreak their towers. Leave the bridge - we might need it.
coop.m06.start = Archers on walls are like pigeons on roofs: loud and easy to remove.
coop.m06.goal = Raze the knight towers.
coop.m06.t1 = Two rivers mean two bridges. Or one very wet army.
coop.m06.t2 = The knights are polishing their armour. Let us give them a reason.
coop.m07.title = The Lava Smithy
coop.m07.story = Between two rivers of lava the knights forge their blessed blades. Their vault overflows with gold paid for by every dungeon they emptied.\n\nWe are taking it back. All of it. And then some.
coop.m07.start = Warm feet, cold hearts, full pockets: my kind of evening.
coop.m07.goal = Loot gold from the knights.
coop.m07.t1 = Do not stand too close to the lava. Your creatures, I mean. You are fine.
coop.m07.t2 = More knights from the east. They really love their gold.
coop.m08.title = The Field Hospital
coop.m08.story = The Lightwatch has set up a field hospital in the caves, and its priests patch up every knight you knock down.\n\nEnd the treatment. Permanently.
coop.m08.start = Priests. They heal. We do the opposite. It is a balanced relationship.
coop.m08.goal = Defeat every knight.
coop.m08.t1 = Every minute a new priest arrives. Kill faster, masters.
coop.m08.t2 = A dungeon without hospital bills. That is what I call efficient.
coop.m09.title = Mossgrim's Ambassador
coop.m09.story = Mossgrim has sent an ambassador to the knights: a paladin in green-stained armour who carries his master's promises.\n\nPromises should never arrive. Find the ambassador and silence him.
coop.m09.start = An ambassador. A messenger with a fancy hat. Hats come off.
coop.m09.goal = Defeat Mossgrim's ambassador.
coop.m09.boss = The ambassador has fallen. His message: undelivered.
coop.m09.t1 = The ambassador waits in the east, surrounded by ogres who lost their sense of humour.
coop.m09.t2 = Somewhere Mossgrim is sending another letter. Let us make it a short one.
coop.m10.title = The Twin Towers
coop.m10.story = Two fortresses guard the way north, each watching the other's back. Strike one and the other answers.\n\nStrike both. At once. That is what two masters are for.
coop.m10.start = Two fortresses, two masters. Fair is fair.
coop.m10.goal = Raze the knight towers.
coop.m10.t1 = Split up, masters - one fortress each. I will cheer for both.
coop.m10.t2 = When both fall at once, the knights will not even know whom to blame.
coop.m11.title = The Siege
coop.m11.story = The Lightwatch has found your hearts and brings everything: five waves of knights and Mossgrim's gargoyles from the cliffs.\n\nNo cleverness this time. Just walls, traps, towers - and each other.
coop.m11.start = They are coming. All of them. Well, most of them.
coop.m11.goal = Survive five waves.
coop.m11.t1 = Doors and traps, masters. A siege is just guests who refuse to use the bell.
coop.m11.t2 = Gargoyles from the south. Stone with wings - nobody asked for that.
coop.m12.title = The Gate
coop.m12.story = The hero gate in this cavern is how the knights reach the deep dungeons. Close it and their reinforcements must walk the long way round.\n\nBreak the gate, and the Lightwatch will feel it all the way up to the High Marshal.
coop.m12.start = A gate. Gates are just walls with ambitions.
coop.m12.goal = Destroy the hero gate.
coop.m12.t1 = Waves keep coming out of it. Annoying, like a dripping tap.
coop.m12.t2 = Push, masters! The gate is cracking. So is my patience.
coop.m13.title = Mossy Tunnels
coop.m13.story = We are in Mossgrim's backyard now. His tunnels are soft and damp, and two of his hoards lie in sealed caves.\n\nDig, take, leave. Mossgrim will notice. That is the point.
coop.m13.start = It smells of mushrooms and treachery. Mossgrim must be near.
coop.m13.goal = Loot gold.
coop.m13.t1 = Spiders, again. Mossgrim breeds them like other people grow tomatoes.
coop.m13.t2 = His hoards are bigger than I thought. Our pockets are bigger still.
coop.m14.title = Poison in the Water
coop.m14.story = Mossgrim has turned his dead into revenants and sends them from two sides. The knights call it holy work. Typical.\n\nDefeat every knight and every walking corpse. Then wash your hands.
coop.m14.start = Revenants. Our trick, stolen. I am offended.
coop.m14.goal = Defeat every enemy.
coop.m14.t1 = They come from the north and the south. Back to back, masters.
coop.m14.t2 = A dead thing that walks is a dead thing that has not finished its job yet.
coop.m15.title = The Chainbreaker
coop.m15.story = Deep in the rock the knights keep the Chainbreaker, a creature even they fear. Free it, and it will fight for whoever opened its cell.\n\nOr grab enough gold for the long way round - Crookfinger knows a smuggler.
coop.m15.start = The Chainbreaker. Big, angry, misunderstood. A bit like me.
coop.m15.goal = Free the Chainbreaker and raze the towers.
coop.m15.branch = Bought the smuggler's map
coop.m15.t1 = The cell is in the north-west. Mind its temper.
coop.m15.t2 = The smuggler is waiting. Smugglers hate waiting. And love gold.
coop.m16.title = The Burning Spring
coop.m16.story = In the middle of a lava lake lies an island with a mana spring that never runs dry. Mossgrim's mages guard it.\n\nTake the spring, defeat the guards. Then sit on the island and enjoy the warmth.
coop.m16.start = A spring in lava. Whoever thought of that has style. Shame it is Mossgrim.
coop.m16.goal = Claim the spring and defeat every enemy.
coop.m16.t1 = Mages from the east. Their spells are green and so are their teeth.
coop.m16.t2 = Once the spring is ours, mana for everyone. Share nicely.
coop.m17.title = Mossgrim's Garden
coop.m17.story = This is it: Mossgrim's garden, a cavern of mushrooms and creatures loyal to him. Ogres in the east, an ember wyrm in the north.\n\nTrample the garden. Every mushroom.
coop.m17.start = Look at all those mushrooms. Pity about the owner.
coop.m17.goal = Defeat every enemy.
coop.m17.t1 = An ember wyrm! Mossgrim must have saved up for years.
coop.m17.t2 = The garden is wilting. Gardening is not your strength, masters. That is a compliment.
coop.m18.title = The Last Fortress
coop.m18.story = Three fortresses guard the stairs down to the High Marshal's hall. They are the last thing between you and the end of the Lightwatch's crusade.\n\nRaze them all. Then the stairs are yours.
coop.m18.start = Three fortresses. They really did save the best for last.
coop.m18.goal = Raze the knight towers.
coop.m18.t1 = Each master a fortress, and the third one together. I will count.
coop.m18.t2 = I can see the stairs already. Dusty. Nobody comes down here voluntarily.
coop.m19.title = The High Marshal
coop.m19.story = The High Marshal of the Lightwatch himself, in armour polished for a hundred years. His guards come from north and south.\n\nBring him down, and the Order will have nobody left to give the orders.
coop.m19.start = The High Marshal. I have dreamed of this. Mostly the part where he falls over.
coop.m19.goal = Defeat the High Marshal.
coop.m19.boss = The High Marshal has fallen! His armour clatters like a dropped cutlery drawer.
coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first.
coop.m19.t2 = Knights and archers again. The Order is running out of ideas.
coop.m20.title = Mossgrim's Fall
coop.m20.story = With the High Marshal gone, Mossgrim stands alone. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nEnd the traitor. End his army. And then, masters - tea.
coop.m20.start = Mossgrim, you old fungus. The league sends its regards.
coop.m20.goal = Defeat Mossgrim and all his creatures.
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not.
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go.
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong.
+144
View File
@@ -1339,3 +1339,147 @@ blk.w_seconds = secondi
blk.help.until = Esegue i blocchi interni più volte finché la condizione è vera; viene controllata prima di ogni giro, quindi se è già vera il contenuto non viene eseguito. blk.help.until = Esegue i blocchi interni più volte finché la condizione è vera; viene controllata prima di ogni giro, quindi se è già vera il contenuto non viene eseguito.
blk.help.dountil = Esegue i blocchi interni, poi controlla la condizione e ripete finché è vera; il contenuto viene eseguito almeno una volta. blk.help.dountil = Esegue i blocchi interni, poi controlla la condizione e ripete finché è vera; il contenuto viene eseguito almeno una volta.
blk.help.wait = Mette in pausa questo script per alcuni secondi, poi prosegue con il blocco successivo (anche nei cicli). blk.help.wait = Mette in pausa questo script per alcuni secondi, poi prosegue con il blocco successivo (anche nei cicli).
msg.master_out = {0} ha perso il nucleo delle anime: le sue creature combattono senza cuore.
msg.master_out_you = Il tuo nucleo delle anime è andato in frantumi. Sei fuori: guarda il resto della partita.
msg.match_won = La tua squadra ha vinto la partita!
msg.match_lost = La partita è persa: un'altra squadra ha vinto.
msg.gold_received = {1} ti manda {0} oro.
msg.gold_sent = {0} oro inviato a {1}.
msg.gold_none = Non hai oro da inviare.
msg.gold_full = Il tesoro di {0} è pieno.
gold.send_to = Oro a {0}
gold.all = Tutto
net.spectating = Il tuo cuore è caduto: stai guardando la partita (chat con Invio).
net.team = Squadra {0}
net.versus = Tutti contro tutti
net.coop_wait_host = In attesa che l'host avvii la prossima missione...
net.coop_run_over = Torna alla lobby
# ---- co-op campaign "League against the Lightwatch"
coop.name = League against the Lightwatch
coop.raid = Mossgrim's creatures crawl out of the dark!
coop.m01.title = Two Hearts, One Crookfinger
coop.m01.story = Masters, plural. I could hardly believe it either. The Order of the Lightwatch has made a pact with Mossgrim, that mossy turncoat, to smoke out every dungeon under the hills.\n\nAlone, you would be smoked. Together you might just be the smoke. Dig in side by side and show the patrol nearby what a shared basement means.
coop.m01.start = Two masters, one advisor. My wages, by the way, stay the same.
coop.m01.goal = Defeat every knight.
coop.m01.t1 = Share the work: one digs, one builds. Or both dig. I am flexible.
coop.m01.t2 = The patrol is getting curious. Good. Curiosity is a lovely snack.
coop.m02.title = The First Outpost
coop.m02.story = Across the river the Lightwatch has built an outpost with shiny towers. Shiny things must be broken - that is the oldest rule of the underworld.\n\nBridge the water, bring the towers down, and do it together before they call their friends.
coop.m02.start = Towers. Polished, pointy, insulting. Let us fix that.
coop.m02.goal = Raze the knight towers.
coop.m02.t1 = Bridges are like friendships, masters: build them, then march an army over them.
coop.m02.t2 = One tower down is a start. All towers down is a party.
coop.m03.title = Tracks in the Moss
coop.m03.story = Something green and wet is lurking in the tunnels. Mossgrim has lent the knights his creatures, and they come through the hero gate in waves.\n\nHold out. Hold together. Hold your noses.
coop.m03.start = Mossgrim's ogres smell of wet carpet. You will know when they come.
coop.m03.goal = Survive three waves.
coop.m03.t1 = The first ogres are here. Mossgrim never could keep his pets at home.
coop.m03.t2 = Keep your hearts apart, masters - one ogre should never reach both of them.
coop.m04.title = The Stolen Springs
coop.m04.story = Two mana springs bubble on islands in the lake, and the knights have claimed them as their holy water. Holy water, honestly.\n\nTake both springs. And if the knights' vault happens to fall into your hands first, Crookfinger knows a shortcut.
coop.m04.start = Mana tastes better when it was someone else's.
coop.m04.goal = Claim two mana springs.
coop.m04.branch = Rich enough for the shortcut
coop.m04.t1 = Islands need bridges. Or wings. Bats are wings with teeth.
coop.m04.t2 = That vault looks heavy. Heavy things should be carried home.
coop.m05.title = Chains in the Rock
coop.m05.story = The Lightwatch keeps creatures in chains in sealed caves - creatures that refused to bow. An ogre and a mage, both furious, both available.\n\nDig to them, claim their caves, and the chains will fall.
coop.m05.start = Prisoners! Ours, soon. Free ones, I mean. Free to work for you.
coop.m05.goal = Free two chained creatures.
coop.m05.t1 = Listen. Do you hear the ogre swearing? That is gratitude in advance.
coop.m05.t2 = Spiders from the moss again. Mossgrim's nursery is busy.
coop.m06.title = Bridge of the Watch
coop.m06.story = Two rivers, one fortress, and the Lightwatch's finest archers on the walls. They call it the Bridge of the Watch. We will call it the bridge that was.\n\nBreak their towers. Leave the bridge - we might need it.
coop.m06.start = Archers on walls are like pigeons on roofs: loud and easy to remove.
coop.m06.goal = Raze the knight towers.
coop.m06.t1 = Two rivers mean two bridges. Or one very wet army.
coop.m06.t2 = The knights are polishing their armour. Let us give them a reason.
coop.m07.title = The Lava Smithy
coop.m07.story = Between two rivers of lava the knights forge their blessed blades. Their vault overflows with gold paid for by every dungeon they emptied.\n\nWe are taking it back. All of it. And then some.
coop.m07.start = Warm feet, cold hearts, full pockets: my kind of evening.
coop.m07.goal = Loot gold from the knights.
coop.m07.t1 = Do not stand too close to the lava. Your creatures, I mean. You are fine.
coop.m07.t2 = More knights from the east. They really love their gold.
coop.m08.title = The Field Hospital
coop.m08.story = The Lightwatch has set up a field hospital in the caves, and its priests patch up every knight you knock down.\n\nEnd the treatment. Permanently.
coop.m08.start = Priests. They heal. We do the opposite. It is a balanced relationship.
coop.m08.goal = Defeat every knight.
coop.m08.t1 = Every minute a new priest arrives. Kill faster, masters.
coop.m08.t2 = A dungeon without hospital bills. That is what I call efficient.
coop.m09.title = Mossgrim's Ambassador
coop.m09.story = Mossgrim has sent an ambassador to the knights: a paladin in green-stained armour who carries his master's promises.\n\nPromises should never arrive. Find the ambassador and silence him.
coop.m09.start = An ambassador. A messenger with a fancy hat. Hats come off.
coop.m09.goal = Defeat Mossgrim's ambassador.
coop.m09.boss = The ambassador has fallen. His message: undelivered.
coop.m09.t1 = The ambassador waits in the east, surrounded by ogres who lost their sense of humour.
coop.m09.t2 = Somewhere Mossgrim is sending another letter. Let us make it a short one.
coop.m10.title = The Twin Towers
coop.m10.story = Two fortresses guard the way north, each watching the other's back. Strike one and the other answers.\n\nStrike both. At once. That is what two masters are for.
coop.m10.start = Two fortresses, two masters. Fair is fair.
coop.m10.goal = Raze the knight towers.
coop.m10.t1 = Split up, masters - one fortress each. I will cheer for both.
coop.m10.t2 = When both fall at once, the knights will not even know whom to blame.
coop.m11.title = The Siege
coop.m11.story = The Lightwatch has found your hearts and brings everything: five waves of knights and Mossgrim's gargoyles from the cliffs.\n\nNo cleverness this time. Just walls, traps, towers - and each other.
coop.m11.start = They are coming. All of them. Well, most of them.
coop.m11.goal = Survive five waves.
coop.m11.t1 = Doors and traps, masters. A siege is just guests who refuse to use the bell.
coop.m11.t2 = Gargoyles from the south. Stone with wings - nobody asked for that.
coop.m12.title = The Gate
coop.m12.story = The hero gate in this cavern is how the knights reach the deep dungeons. Close it and their reinforcements must walk the long way round.\n\nBreak the gate, and the Lightwatch will feel it all the way up to the High Marshal.
coop.m12.start = A gate. Gates are just walls with ambitions.
coop.m12.goal = Destroy the hero gate.
coop.m12.t1 = Waves keep coming out of it. Annoying, like a dripping tap.
coop.m12.t2 = Push, masters! The gate is cracking. So is my patience.
coop.m13.title = Mossy Tunnels
coop.m13.story = We are in Mossgrim's backyard now. His tunnels are soft and damp, and two of his hoards lie in sealed caves.\n\nDig, take, leave. Mossgrim will notice. That is the point.
coop.m13.start = It smells of mushrooms and treachery. Mossgrim must be near.
coop.m13.goal = Loot gold.
coop.m13.t1 = Spiders, again. Mossgrim breeds them like other people grow tomatoes.
coop.m13.t2 = His hoards are bigger than I thought. Our pockets are bigger still.
coop.m14.title = Poison in the Water
coop.m14.story = Mossgrim has turned his dead into revenants and sends them from two sides. The knights call it holy work. Typical.\n\nDefeat every knight and every walking corpse. Then wash your hands.
coop.m14.start = Revenants. Our trick, stolen. I am offended.
coop.m14.goal = Defeat every enemy.
coop.m14.t1 = They come from the north and the south. Back to back, masters.
coop.m14.t2 = A dead thing that walks is a dead thing that has not finished its job yet.
coop.m15.title = The Chainbreaker
coop.m15.story = Deep in the rock the knights keep the Chainbreaker, a creature even they fear. Free it, and it will fight for whoever opened its cell.\n\nOr grab enough gold for the long way round - Crookfinger knows a smuggler.
coop.m15.start = The Chainbreaker. Big, angry, misunderstood. A bit like me.
coop.m15.goal = Free the Chainbreaker and raze the towers.
coop.m15.branch = Bought the smuggler's map
coop.m15.t1 = The cell is in the north-west. Mind its temper.
coop.m15.t2 = The smuggler is waiting. Smugglers hate waiting. And love gold.
coop.m16.title = The Burning Spring
coop.m16.story = In the middle of a lava lake lies an island with a mana spring that never runs dry. Mossgrim's mages guard it.\n\nTake the spring, defeat the guards. Then sit on the island and enjoy the warmth.
coop.m16.start = A spring in lava. Whoever thought of that has style. Shame it is Mossgrim.
coop.m16.goal = Claim the spring and defeat every enemy.
coop.m16.t1 = Mages from the east. Their spells are green and so are their teeth.
coop.m16.t2 = Once the spring is ours, mana for everyone. Share nicely.
coop.m17.title = Mossgrim's Garden
coop.m17.story = This is it: Mossgrim's garden, a cavern of mushrooms and creatures loyal to him. Ogres in the east, an ember wyrm in the north.\n\nTrample the garden. Every mushroom.
coop.m17.start = Look at all those mushrooms. Pity about the owner.
coop.m17.goal = Defeat every enemy.
coop.m17.t1 = An ember wyrm! Mossgrim must have saved up for years.
coop.m17.t2 = The garden is wilting. Gardening is not your strength, masters. That is a compliment.
coop.m18.title = The Last Fortress
coop.m18.story = Three fortresses guard the stairs down to the High Marshal's hall. They are the last thing between you and the end of the Lightwatch's crusade.\n\nRaze them all. Then the stairs are yours.
coop.m18.start = Three fortresses. They really did save the best for last.
coop.m18.goal = Raze the knight towers.
coop.m18.t1 = Each master a fortress, and the third one together. I will count.
coop.m18.t2 = I can see the stairs already. Dusty. Nobody comes down here voluntarily.
coop.m19.title = The High Marshal
coop.m19.story = The High Marshal of the Lightwatch himself, in armour polished for a hundred years. His guards come from north and south.\n\nBring him down, and the Order will have nobody left to give the orders.
coop.m19.start = The High Marshal. I have dreamed of this. Mostly the part where he falls over.
coop.m19.goal = Defeat the High Marshal.
coop.m19.boss = The High Marshal has fallen! His armour clatters like a dropped cutlery drawer.
coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first.
coop.m19.t2 = Knights and archers again. The Order is running out of ideas.
coop.m20.title = Mossgrim's Fall
coop.m20.story = With the High Marshal gone, Mossgrim stands alone. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nEnd the traitor. End his army. And then, masters - tea.
coop.m20.start = Mossgrim, you old fungus. The league sends its regards.
coop.m20.goal = Defeat Mossgrim and all his creatures.
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not.
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go.
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong.
+144
View File
@@ -1339,3 +1339,147 @@ blk.w_seconds = сек.
blk.help.until = Снова и снова выполняет блоки внутри, пока условие не станет верным; проверка перед каждым проходом, поэтому если оно уже верно, блоки не выполняются. blk.help.until = Снова и снова выполняет блоки внутри, пока условие не станет верным; проверка перед каждым проходом, поэтому если оно уже верно, блоки не выполняются.
blk.help.dountil = Выполняет блоки внутри, затем проверяет условие и повторяет, пока оно не станет верным; блоки выполняются хотя бы один раз. blk.help.dountil = Выполняет блоки внутри, затем проверяет условие и повторяет, пока оно не станет верным; блоки выполняются хотя бы один раз.
blk.help.wait = Приостанавливает этот скрипт на несколько секунд, затем выполняется следующий блок (и внутри циклов). blk.help.wait = Приостанавливает этот скрипт на несколько секунд, затем выполняется следующий блок (и внутри циклов).
msg.master_out = {0} потерял ядро душ - его существа сражаются дальше без сердца.
msg.master_out_you = Твоё ядро душ разбито. Ты выбыл - смотри оставшуюся часть матча.
msg.match_won = Твоя команда выиграла матч!
msg.match_lost = Матч проигран - победила другая команда.
msg.gold_received = {1} присылает тебе {0} золота.
msg.gold_sent = {0} золота отправлено: {1}.
msg.gold_none = У тебя нет золота для отправки.
msg.gold_full = Сокровищница {0} полна.
gold.send_to = Золото: {0}
gold.all = Всё
net.spectating = Твоё сердце пало - ты наблюдаешь за матчем (чат: Enter).
net.team = Команда {0}
net.versus = Каждый за себя
net.coop_wait_host = Ждём, пока хозяин запустит следующую миссию...
net.coop_run_over = Назад в лобби
# ---- co-op campaign "League against the Lightwatch"
coop.name = League against the Lightwatch
coop.raid = Mossgrim's creatures crawl out of the dark!
coop.m01.title = Two Hearts, One Crookfinger
coop.m01.story = Masters, plural. I could hardly believe it either. The Order of the Lightwatch has made a pact with Mossgrim, that mossy turncoat, to smoke out every dungeon under the hills.\n\nAlone, you would be smoked. Together you might just be the smoke. Dig in side by side and show the patrol nearby what a shared basement means.
coop.m01.start = Two masters, one advisor. My wages, by the way, stay the same.
coop.m01.goal = Defeat every knight.
coop.m01.t1 = Share the work: one digs, one builds. Or both dig. I am flexible.
coop.m01.t2 = The patrol is getting curious. Good. Curiosity is a lovely snack.
coop.m02.title = The First Outpost
coop.m02.story = Across the river the Lightwatch has built an outpost with shiny towers. Shiny things must be broken - that is the oldest rule of the underworld.\n\nBridge the water, bring the towers down, and do it together before they call their friends.
coop.m02.start = Towers. Polished, pointy, insulting. Let us fix that.
coop.m02.goal = Raze the knight towers.
coop.m02.t1 = Bridges are like friendships, masters: build them, then march an army over them.
coop.m02.t2 = One tower down is a start. All towers down is a party.
coop.m03.title = Tracks in the Moss
coop.m03.story = Something green and wet is lurking in the tunnels. Mossgrim has lent the knights his creatures, and they come through the hero gate in waves.\n\nHold out. Hold together. Hold your noses.
coop.m03.start = Mossgrim's ogres smell of wet carpet. You will know when they come.
coop.m03.goal = Survive three waves.
coop.m03.t1 = The first ogres are here. Mossgrim never could keep his pets at home.
coop.m03.t2 = Keep your hearts apart, masters - one ogre should never reach both of them.
coop.m04.title = The Stolen Springs
coop.m04.story = Two mana springs bubble on islands in the lake, and the knights have claimed them as their holy water. Holy water, honestly.\n\nTake both springs. And if the knights' vault happens to fall into your hands first, Crookfinger knows a shortcut.
coop.m04.start = Mana tastes better when it was someone else's.
coop.m04.goal = Claim two mana springs.
coop.m04.branch = Rich enough for the shortcut
coop.m04.t1 = Islands need bridges. Or wings. Bats are wings with teeth.
coop.m04.t2 = That vault looks heavy. Heavy things should be carried home.
coop.m05.title = Chains in the Rock
coop.m05.story = The Lightwatch keeps creatures in chains in sealed caves - creatures that refused to bow. An ogre and a mage, both furious, both available.\n\nDig to them, claim their caves, and the chains will fall.
coop.m05.start = Prisoners! Ours, soon. Free ones, I mean. Free to work for you.
coop.m05.goal = Free two chained creatures.
coop.m05.t1 = Listen. Do you hear the ogre swearing? That is gratitude in advance.
coop.m05.t2 = Spiders from the moss again. Mossgrim's nursery is busy.
coop.m06.title = Bridge of the Watch
coop.m06.story = Two rivers, one fortress, and the Lightwatch's finest archers on the walls. They call it the Bridge of the Watch. We will call it the bridge that was.\n\nBreak their towers. Leave the bridge - we might need it.
coop.m06.start = Archers on walls are like pigeons on roofs: loud and easy to remove.
coop.m06.goal = Raze the knight towers.
coop.m06.t1 = Two rivers mean two bridges. Or one very wet army.
coop.m06.t2 = The knights are polishing their armour. Let us give them a reason.
coop.m07.title = The Lava Smithy
coop.m07.story = Between two rivers of lava the knights forge their blessed blades. Their vault overflows with gold paid for by every dungeon they emptied.\n\nWe are taking it back. All of it. And then some.
coop.m07.start = Warm feet, cold hearts, full pockets: my kind of evening.
coop.m07.goal = Loot gold from the knights.
coop.m07.t1 = Do not stand too close to the lava. Your creatures, I mean. You are fine.
coop.m07.t2 = More knights from the east. They really love their gold.
coop.m08.title = The Field Hospital
coop.m08.story = The Lightwatch has set up a field hospital in the caves, and its priests patch up every knight you knock down.\n\nEnd the treatment. Permanently.
coop.m08.start = Priests. They heal. We do the opposite. It is a balanced relationship.
coop.m08.goal = Defeat every knight.
coop.m08.t1 = Every minute a new priest arrives. Kill faster, masters.
coop.m08.t2 = A dungeon without hospital bills. That is what I call efficient.
coop.m09.title = Mossgrim's Ambassador
coop.m09.story = Mossgrim has sent an ambassador to the knights: a paladin in green-stained armour who carries his master's promises.\n\nPromises should never arrive. Find the ambassador and silence him.
coop.m09.start = An ambassador. A messenger with a fancy hat. Hats come off.
coop.m09.goal = Defeat Mossgrim's ambassador.
coop.m09.boss = The ambassador has fallen. His message: undelivered.
coop.m09.t1 = The ambassador waits in the east, surrounded by ogres who lost their sense of humour.
coop.m09.t2 = Somewhere Mossgrim is sending another letter. Let us make it a short one.
coop.m10.title = The Twin Towers
coop.m10.story = Two fortresses guard the way north, each watching the other's back. Strike one and the other answers.\n\nStrike both. At once. That is what two masters are for.
coop.m10.start = Two fortresses, two masters. Fair is fair.
coop.m10.goal = Raze the knight towers.
coop.m10.t1 = Split up, masters - one fortress each. I will cheer for both.
coop.m10.t2 = When both fall at once, the knights will not even know whom to blame.
coop.m11.title = The Siege
coop.m11.story = The Lightwatch has found your hearts and brings everything: five waves of knights and Mossgrim's gargoyles from the cliffs.\n\nNo cleverness this time. Just walls, traps, towers - and each other.
coop.m11.start = They are coming. All of them. Well, most of them.
coop.m11.goal = Survive five waves.
coop.m11.t1 = Doors and traps, masters. A siege is just guests who refuse to use the bell.
coop.m11.t2 = Gargoyles from the south. Stone with wings - nobody asked for that.
coop.m12.title = The Gate
coop.m12.story = The hero gate in this cavern is how the knights reach the deep dungeons. Close it and their reinforcements must walk the long way round.\n\nBreak the gate, and the Lightwatch will feel it all the way up to the High Marshal.
coop.m12.start = A gate. Gates are just walls with ambitions.
coop.m12.goal = Destroy the hero gate.
coop.m12.t1 = Waves keep coming out of it. Annoying, like a dripping tap.
coop.m12.t2 = Push, masters! The gate is cracking. So is my patience.
coop.m13.title = Mossy Tunnels
coop.m13.story = We are in Mossgrim's backyard now. His tunnels are soft and damp, and two of his hoards lie in sealed caves.\n\nDig, take, leave. Mossgrim will notice. That is the point.
coop.m13.start = It smells of mushrooms and treachery. Mossgrim must be near.
coop.m13.goal = Loot gold.
coop.m13.t1 = Spiders, again. Mossgrim breeds them like other people grow tomatoes.
coop.m13.t2 = His hoards are bigger than I thought. Our pockets are bigger still.
coop.m14.title = Poison in the Water
coop.m14.story = Mossgrim has turned his dead into revenants and sends them from two sides. The knights call it holy work. Typical.\n\nDefeat every knight and every walking corpse. Then wash your hands.
coop.m14.start = Revenants. Our trick, stolen. I am offended.
coop.m14.goal = Defeat every enemy.
coop.m14.t1 = They come from the north and the south. Back to back, masters.
coop.m14.t2 = A dead thing that walks is a dead thing that has not finished its job yet.
coop.m15.title = The Chainbreaker
coop.m15.story = Deep in the rock the knights keep the Chainbreaker, a creature even they fear. Free it, and it will fight for whoever opened its cell.\n\nOr grab enough gold for the long way round - Crookfinger knows a smuggler.
coop.m15.start = The Chainbreaker. Big, angry, misunderstood. A bit like me.
coop.m15.goal = Free the Chainbreaker and raze the towers.
coop.m15.branch = Bought the smuggler's map
coop.m15.t1 = The cell is in the north-west. Mind its temper.
coop.m15.t2 = The smuggler is waiting. Smugglers hate waiting. And love gold.
coop.m16.title = The Burning Spring
coop.m16.story = In the middle of a lava lake lies an island with a mana spring that never runs dry. Mossgrim's mages guard it.\n\nTake the spring, defeat the guards. Then sit on the island and enjoy the warmth.
coop.m16.start = A spring in lava. Whoever thought of that has style. Shame it is Mossgrim.
coop.m16.goal = Claim the spring and defeat every enemy.
coop.m16.t1 = Mages from the east. Their spells are green and so are their teeth.
coop.m16.t2 = Once the spring is ours, mana for everyone. Share nicely.
coop.m17.title = Mossgrim's Garden
coop.m17.story = This is it: Mossgrim's garden, a cavern of mushrooms and creatures loyal to him. Ogres in the east, an ember wyrm in the north.\n\nTrample the garden. Every mushroom.
coop.m17.start = Look at all those mushrooms. Pity about the owner.
coop.m17.goal = Defeat every enemy.
coop.m17.t1 = An ember wyrm! Mossgrim must have saved up for years.
coop.m17.t2 = The garden is wilting. Gardening is not your strength, masters. That is a compliment.
coop.m18.title = The Last Fortress
coop.m18.story = Three fortresses guard the stairs down to the High Marshal's hall. They are the last thing between you and the end of the Lightwatch's crusade.\n\nRaze them all. Then the stairs are yours.
coop.m18.start = Three fortresses. They really did save the best for last.
coop.m18.goal = Raze the knight towers.
coop.m18.t1 = Each master a fortress, and the third one together. I will count.
coop.m18.t2 = I can see the stairs already. Dusty. Nobody comes down here voluntarily.
coop.m19.title = The High Marshal
coop.m19.story = The High Marshal of the Lightwatch himself, in armour polished for a hundred years. His guards come from north and south.\n\nBring him down, and the Order will have nobody left to give the orders.
coop.m19.start = The High Marshal. I have dreamed of this. Mostly the part where he falls over.
coop.m19.goal = Defeat the High Marshal.
coop.m19.boss = The High Marshal has fallen! His armour clatters like a dropped cutlery drawer.
coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first.
coop.m19.t2 = Knights and archers again. The Order is running out of ideas.
coop.m20.title = Mossgrim's Fall
coop.m20.story = With the High Marshal gone, Mossgrim stands alone. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nEnd the traitor. End his army. And then, masters - tea.
coop.m20.start = Mossgrim, you old fungus. The league sends its regards.
coop.m20.goal = Defeat Mossgrim and all his creatures.
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not.
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go.
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong.
+193 -7
View File
@@ -19,9 +19,14 @@
// {"t":"resume"} -> side 0 resumes a paused match // {"t":"resume"} -> side 0 resumes a paused match
// {"t":"abort"} -> side 0 closes the match // {"t":"abort"} -> side 0 closes the match
// {"t":"set_ai","side":2,"ai":true} -> the host marks an empty seat as AI, or clears it again // {"t":"set_ai","side":2,"ai":true} -> the host marks an empty seat as AI, or clears it again
// {"t":"set_team","team":1,"side":3?} -> team 0..3 (A-D) of the sender's seat before the start; the
// host may name an empty or AI seat with "side"
// {"t":"next","mission":3,"seed":77?,"carry":[..]?} -> the host starts the next mission of an editor campaign
// co-op match; every client gets "next" (no campaign: they
// have it) with the seed, sides, AI seats and the carry
// {"t":"leave"} -> a player leaves; a started human seat becomes AI-controlled while anyone is connected // {"t":"leave"} -> a player leaves; a started human seat becomes AI-controlled while anyone is connected
// //
// Server messages include: hello, rooms, room, start, turn, sum, chat, closed, error, pong. // Server messages include: hello, rooms, room, start, next, turn, sum, chat, closed, error, pong.
use crate::AppState; use crate::AppState;
use axum::{ use axum::{
@@ -88,6 +93,15 @@ struct Seat {
name: String, name: String,
ready: bool, ready: bool,
ai: bool, ai: bool,
team: u8, // 0..3 (A-D) in a versus match; by default every seat is a team of its own
}
fn default_team(side: u8) -> u8 {
MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8
}
fn new_seat(side: u8, ai: bool) -> Seat {
Seat { conn: None, name: String::new(), ready: false, ai, team: default_team(side) }
} }
impl Room { impl Room {
@@ -126,6 +140,19 @@ impl Room {
self.desired_ai_sides() self.desired_ai_sides()
} }
} }
// the team of every side of `sides`, in the same order
fn teams_of(&self, sides: &[u8]) -> Vec<u8> {
sides.iter().map(|s| self.seats.get(s).map_or(default_team(*s), |seat| seat.team)).collect()
}
// the name of the editor campaign and the title of its current mission (room list)
fn campaign_names(&self) -> (Option<String>, Option<String>) {
let Some(doc) = &self.doc else { return (None, None) };
let name = doc.get("name").and_then(|n| n.as_str()).map(|n| n.chars().take(40).collect::<String>());
let title = self.mission.and_then(|m| doc.get("missions").and_then(|ms| ms.get(m))).and_then(|m| m.get("title")).and_then(|t| t.as_str()).map(|t| t.chars().take(40).collect::<String>());
(name, title)
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -269,6 +296,8 @@ impl Netplay {
"take_side" => self.take_side(conn, &msg), "take_side" => self.take_side(conn, &msg),
"ready" => self.ready(conn, &msg), "ready" => self.ready(conn, &msg),
"set_ai" => self.set_ai(conn, &msg), "set_ai" => self.set_ai(conn, &msg),
"set_team" => self.set_team(conn, &msg),
"next" => self.next(conn, &msg),
"start" => self.start(conn), "start" => self.start(conn),
"cmds" => self.cmds(conn, &msg), "cmds" => self.cmds(conn, &msg),
"sum" => self.sum(conn, &msg), "sum" => self.sum(conn, &msg),
@@ -345,7 +374,7 @@ impl Netplay {
} }
} }
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into()); let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
let seats = sides.iter().map(|s| (*s, Seat { conn: None, name: String::new(), ready: false, ai: false })).collect::<HashMap<_, _>>(); let seats = sides.iter().map(|s| (*s, new_seat(*s, false))).collect::<HashMap<_, _>>();
let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission, doc }; let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission, doc };
self.rooms.insert(code.clone(), room); self.rooms.insert(code.clone(), room);
if let Some(c) = self.clients.get_mut(&conn) { if let Some(c) = self.clients.get_mut(&conn) {
@@ -493,7 +522,7 @@ impl Netplay {
} }
r.sides.push(side); r.sides.push(side);
sort_sides(&mut r.sides); sort_sides(&mut r.sides);
r.seats.insert(side, Seat { conn: None, name: String::new(), ready: false, ai: true }); r.seats.insert(side, new_seat(side, true));
} else { } else {
if r.seats[&side].conn.is_some() { if r.seats[&side].conn.is_some() {
return self.error(conn, "seat is taken"); return self.error(conn, "seat is taken");
@@ -512,6 +541,68 @@ impl Netplay {
} }
} }
fn set_team(&mut self, conn: u64, msg: &Value) {
let team = match msg.get("team").and_then(|v| v.as_u64()) {
Some(n) if n < MASTER_SIDES.len() as u64 => n as u8,
_ => return self.error(conn, "unknown team"),
};
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
Some(c) => c,
None => return self.error(conn, "not in a match"),
};
let Some(own) = self.side_of(&code, conn) else { return self.error(conn, "no seat") };
let side = msg.get("side").and_then(|v| v.as_u64()).map_or(own, |n| n as u8);
if let Some(r) = self.rooms.get_mut(&code) {
if r.started {
return self.error(conn, "teams can only be changed before the start");
}
let Some(seat) = r.seats.get(&side) else { return self.error(conn, "side not in match") };
if side != own && (own != 0 || seat.conn.is_some()) {
return self.error(conn, "only the red master can set the team of a free or AI seat");
}
if let Some(seat) = r.seats.get_mut(&side) {
seat.team = team;
}
}
self.room_msg_all(&code);
}
// the next mission of an editor campaign co-op match: the host decided it (the branch follows
// the reached goal) and hands the carry-over of every side along; the clients already have the
// campaign. The turn clock starts again.
fn next(&mut self, conn: u64, msg: &Value) {
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
Some(c) => c,
None => return self.error(conn, "not in a match"),
};
let mission = mission_of(msg.get("mission"));
let seed = msg.get("seed").and_then(|v| v.as_u64()).map(|s| s as u32);
let carry = msg.get("carry").filter(|c| c.is_array()).cloned().unwrap_or(Value::Array(Vec::new()));
let out = match self.rooms.get_mut(&code) {
Some(r) => {
if !r.started {
return self.error(conn, "match not started");
}
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
return self.error(conn, "only the red master can start the next mission");
}
let count = r.doc.as_ref().and_then(|d| d.get("missions")).and_then(|m| m.as_array()).map_or(0, |m| m.len());
let Some(m) = mission.filter(|m| *m < count) else { return self.error(conn, "unknown campaign mission") };
r.mission = Some(m);
r.seed = Some(seed.unwrap_or_else(random_seed));
r.turn = 0;
r.pending.clear();
r.paused = false;
r.missing = false;
r.sides = r.active_sides();
json!({"t":"next","seed":r.seed.unwrap_or(0),"difficulty":r.difficulty,"sides":r.sides,"teams":r.teams_of(&r.sides),"mission":m,"ai":r.started_ai_sides(),"carry":carry})
}
None => return self.error(conn, "not in a match"),
};
self.room_msg_all(&code);
self.broadcast_room(&code, out);
}
fn start(&mut self, conn: u64) { fn start(&mut self, conn: u64) {
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) { let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
Some(c) => c, Some(c) => c,
@@ -545,7 +636,7 @@ impl Netplay {
} }
let msg = match self.rooms.get(&code) { let msg = match self.rooms.get(&code) {
Some(r) => { Some(r) => {
let mut m = json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides,"mission":r.mission,"ai":r.started_ai_sides()}); let mut m = json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides,"teams":r.teams_of(&r.sides),"mission":r.mission,"ai":r.started_ai_sides()});
if let Some(doc) = &r.doc { if let Some(doc) = &r.doc {
m["doc"] = doc.clone(); m["doc"] = doc.clone();
} }
@@ -763,9 +854,10 @@ impl Netplay {
let sides: Vec<u8> = if r.started { r.active_sides() } else { r.sides.clone() }; let sides: Vec<u8> = if r.started { r.active_sides() } else { r.sides.clone() };
let seats: Vec<Value> = sides let seats: Vec<Value> = sides
.iter() .iter()
.filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some(),"ai":s.ai}))) .filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some(),"ai":s.ai,"team":s.team})))
.collect(); .collect();
let ai: Vec<u8> = r.room_ai_sides(); let ai: Vec<u8> = r.room_ai_sides();
let (campaign, title) = r.campaign_names();
Some(json!({ Some(json!({
"t":"room", "t":"room",
"code":r.code, "code":r.code,
@@ -781,7 +873,9 @@ impl Netplay {
"difficulty":r.difficulty, "difficulty":r.difficulty,
"seed":r.seed, "seed":r.seed,
"mission":r.mission, "mission":r.mission,
"editor":r.doc.is_some() "editor":r.doc.is_some(),
"campaign":campaign,
"title":title
})) }))
} }
@@ -791,7 +885,8 @@ impl Netplay {
.values() .values()
.map(|r| { .map(|r| {
let players = r.seats.values().filter(|s| s.conn.is_some()).count(); let players = r.seats.values().filter(|s| s.conn.is_some()).count();
json!({"code":r.code,"players":players,"sides":r.sides,"started":r.started,"paused":r.paused,"mission":r.mission,"editor":r.doc.is_some()}) let (campaign, title) = r.campaign_names();
json!({"code":r.code,"players":players,"sides":r.sides,"teams":r.teams_of(&r.sides),"started":r.started,"paused":r.paused,"mission":r.mission,"editor":r.doc.is_some(),"campaign":campaign,"title":title})
}) })
.collect(); .collect();
self.send(conn, json!({"t":"rooms","rooms":rooms})); self.send(conn, json!({"t":"rooms","rooms":rooms}));
@@ -917,6 +1012,97 @@ mod tests {
assert!(start.get("doc").is_none()); assert!(start.get("doc").is_none());
} }
#[test]
fn players_choose_their_teams_and_the_start_carries_them() {
let mut net = Netplay::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let red = net.connect(tx, Some("red".into()), None);
let rooms = net.handle(red, json!({"t":"create","sides":[0,2,3]}));
let code = rooms[0].clone();
let room = until(&mut rx, "room");
let teams: Vec<u64> = room["seats"].as_array().unwrap().iter().map(|s| s["team"].as_u64().unwrap()).collect();
assert_eq!(teams, vec![0, 1, 2], "every seat is its own team at first");
let (tx2, mut rx2) = mpsc::unbounded_channel();
let green = net.connect(tx2, Some("green".into()), None);
net.handle(green, json!({"t":"join","code":&code}));
// green joins red's team; green may not decide for blue's seat, red (host) may
net.handle(green, json!({"t":"set_team","team":0}));
net.handle(green, json!({"t":"set_team","team":3,"side":3}));
assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can set the team of a free or AI seat"));
net.handle(red, json!({"t":"set_ai","side":3,"ai":true}));
net.handle(red, json!({"t":"set_team","team":3,"side":3}));
net.handle(red, json!({"t":"set_team","team":9}));
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown team"));
let room = net.room_json(&code).unwrap();
let team = |side: u64| room["seats"].as_array().unwrap().iter().find(|s| s["side"].as_u64() == Some(side)).unwrap()["team"].as_u64();
assert_eq!((team(0), team(2), team(3)), (Some(0), Some(0), Some(3)));
net.handle(red, json!({"t":"rooms"}));
let list = until(&mut rx, "rooms");
assert_eq!(list["rooms"][0]["teams"], json!([0, 0, 3]));
net.handle(red, json!({"t":"ready"}));
net.handle(green, json!({"t":"ready"}));
net.handle(red, json!({"t":"start"}));
let start = until(&mut rx2, "start");
assert_eq!(start["sides"], json!([0, 2, 3]));
assert_eq!(start["teams"], json!([0, 0, 3]));
net.handle(red, json!({"t":"set_team","team":1}));
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("teams can only be changed before the start"));
}
#[test]
fn the_host_starts_the_next_editor_mission_for_everybody() {
let mut net = Netplay::new();
let (tx, mut rx) = mpsc::unbounded_channel();
let red = net.connect(tx, Some("red".into()), None);
let doc = json!({"name":"Deep Run","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second"}]});
let rooms = net.handle(red, json!({"t":"create","sides":[0,2],"map_w":96,"map_h":96,"mission":0,"doc":doc}));
let code = rooms[0].clone();
let (tx2, mut rx2) = mpsc::unbounded_channel();
let green = net.connect(tx2, Some("green".into()), None);
net.handle(green, json!({"t":"rooms"}));
let list = until(&mut rx2, "rooms");
assert_eq!(list["rooms"][0]["campaign"].as_str(), Some("Deep Run"), "the room list names the campaign");
assert_eq!(list["rooms"][0]["title"].as_str(), Some("First"));
net.handle(green, json!({"t":"join","code":&code}));
net.handle(red, json!({"t":"ready"}));
net.handle(green, json!({"t":"ready"}));
net.handle(red, json!({"t":"start"}));
until(&mut rx, "start");
until(&mut rx2, "start");
net.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":0,"act":"CancelOrders"}]}));
net.tick(&code);
net.tick(&code);
assert_eq!(net.rooms[&code].turn, 2);
// only the host decides; the mission must exist
net.handle(green, json!({"t":"next","mission":1}));
assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can start the next mission"));
net.handle(red, json!({"t":"next","mission":5}));
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown campaign mission"));
net.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":1,"act":"CancelOrders"}]}));
let carry = json!([[0, {"creatures":[["Ogre",3,10.0]],"gold":300.0}], [2, {"creatures":[],"gold":40.0}]]);
net.handle(red, json!({"t":"next","mission":1,"seed":4711,"carry":carry}));
let next_red = until(&mut rx, "next");
let next_green = until(&mut rx2, "next");
assert_eq!(next_green["mission"].as_u64(), Some(1));
assert_eq!(next_green["seed"].as_u64(), Some(4711), "the run seed of the host");
assert_eq!(next_green["sides"], json!([0, 2]));
assert_eq!(next_green["carry"], carry, "the carry goes to everybody");
assert_eq!(next_red, next_green);
assert!(next_green.get("doc").is_none(), "the clients have the campaign already");
let room = net.room_json(&code).unwrap();
assert_eq!(room["title"].as_str(), Some("Second"));
assert_eq!(room["turn"].as_u64(), Some(0), "the turn clock starts again");
net.tick(&code);
let turn = until(&mut rx2, "turn");
assert_eq!(turn["turn"].as_u64(), Some(1));
assert_eq!(turn["cmds"], json!([]), "orders of the old mission are dropped");
}
#[test] #[test]
fn a_non_host_cannot_start_a_match() { fn a_non_host_cannot_start_a_match() {
let mut net = Netplay::new(); let mut net = Netplay::new();
+53
View File
@@ -734,6 +734,20 @@ pub fn editor_fort(g: &mut Game, x: i32, y: i32, level: u32, shard: bool) {
g.recount(); g.recount();
} }
// an island in a lake (water or lava) at a map fraction, with a mana spring and/or treasure
pub fn editor_island(g: &mut Game, at: (f32, f32), lava: bool, spring: bool, treasure: i32, seed: u32) {
let (w, h) = (g.map.w, g.map.h);
place_island(g, &island(at, lava, spring, treasure), w, h, seed);
g.recount();
}
// a sealed cave at a map fraction, with a chained creature and/or treasure
pub fn editor_cave(g: &mut Game, at: (f32, f32), chained: Option<(EK, u32)>, treasure: i32) {
let (w, h) = (g.map.w, g.map.h);
place_cave(g, &cave(at, chained, treasure), w, h);
g.recount();
}
// a painted map: the game state saved by the map editor // a painted map: the game state saved by the map editor
pub fn load_fixed_map(json: &str) -> Option<Game> { pub fn load_fixed_map(json: &str) -> Option<Game> {
let mut g: Game = serde_json::from_str(json).ok()?; let mut g: Game = serde_json::from_str(json).ok()?;
@@ -774,6 +788,45 @@ pub fn carry_from(g: &Game) -> Carry {
Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor(), rune_free: g.rune_free, rune_final: g.rune_final + g.rune_next } Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor(), rune_free: g.rune_free, rune_final: g.rune_final + g.rune_next }
} }
// a co-op match of an editor campaign: what every master takes into the next mission - his
// strongest creatures and a fifth of his gold. It comes from the shared world, so it is the same on
// every client (the host sends it along with the next mission anyway).
pub fn coop_carry_from(g: &Game) -> Vec<(u8, Carry)> {
let power = |e: &Ent| e.max_hp * e.atk() / st(e.kind).interval;
g.master_sides()
.into_iter()
.map(|side| {
let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && !e.hero && e.side == side && e.kind != EK::Worker && !e.chained && e.captive == 0).collect();
c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal).then(a.id.cmp(&b.id)));
let creatures = c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect();
(side, Carry { creatures, gold: (g.gold_of(side).max(0.0) * CARRY_GOLD).floor(), rune_free: 0, rune_final: 0 })
})
.collect()
}
// the carry of every master put into a new co-op mission: gold for him, his creatures around his heart
pub fn apply_coop_carry(g: &mut Game, carry: &[(u8, Carry)]) {
let local = g.local_side;
for (side, c) in carry {
if !g.is_master(*side) {
continue; // the seat is played by the AI now
}
g.switch_to(*side);
g.gold += c.gold.max(0.0);
let (cx, cy) = g.map.core;
for (k, &(kind, level, xp)) in c.creatures.iter().take(CARRY_CREATURES).enumerate() {
let a = k as f32 * 1.1;
let (x, y) = (cx as f32 + 1.5 + a.cos() * 3.0, cy as f32 + 1.5 + a.sin() * 3.0);
let id = g.spawn(kind, false, x, y, level.clamp(1, 10));
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.xp = xp;
e.happy = 1.0;
}
}
}
g.switch_to(local);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+10 -2
View File
@@ -85,6 +85,11 @@ pub enum Act {
y: i32, y: i32,
}, },
CancelOrders, CancelOrders,
// gold for an allied master of a match (`crate::netplay::GOLD_ALL`: everything)
SendGold {
to: u8,
amount: u32,
},
} }
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
@@ -97,7 +102,8 @@ pub struct Command {
impl Game { impl Game {
// the local master orders something: queued until the next step boundary // the local master orders something: queued until the next step boundary
pub fn queue(&mut self, act: Act) { pub fn queue(&mut self, act: Act) {
if self.state != 0 || self.paused || self.demo.is_some() || !self.is_master(self.local_side) { // a master whose heart fell only watches
if self.state != 0 || self.paused || self.demo.is_some() || !self.is_master(self.local_side) || self.is_out(self.local_side) {
return; return;
} }
let seq = self.cmd_seq; let seq = self.cmd_seq;
@@ -109,7 +115,7 @@ impl Game {
// one command of one master: that master's state is switched in, the action runs, the local // one command of one master: that master's state is switched in, the action runs, the local
// master's state is switched back. Messages, hints and sounds only come from the local master. // master's state is switched back. Messages, hints and sounds only come from the local master.
pub fn apply(&mut self, c: &Command) { pub fn apply(&mut self, c: &Command) {
if !self.is_master(c.side) { if !self.is_master(c.side) || self.is_out(c.side) {
return; return;
} }
self.switch_to(c.side); self.switch_to(c.side);
@@ -131,6 +137,7 @@ impl Game {
fn mark_command(&mut self, act: Act, side: u8) { fn mark_command(&mut self, act: Act, side: u8) {
let ((x, y), kind, dur) = match act { let ((x, y), kind, dur) = match act {
Act::SendGold { .. } => return, // nothing to point at on the map
Act::Dig(r) => (rect_center(r), MARK_DIG, 4.0), Act::Dig(r) => (rect_center(r), MARK_DIG, 4.0),
Act::Room { r, .. } => (rect_center(r), MARK_ROOM, 5.0), Act::Room { r, .. } => (rect_center(r), MARK_ROOM, 5.0),
Act::Sell(r) => (rect_center(r), MARK_SELL, 4.0), Act::Sell(r) => (rect_center(r), MARK_SELL, 4.0),
@@ -203,6 +210,7 @@ impl Game {
} }
Act::Guard { x, y } => self.order_guard(x, y), Act::Guard { x, y } => self.order_guard(x, y),
Act::CancelOrders => self.cancel_orders(), Act::CancelOrders => self.cancel_orders(),
Act::SendGold { to, amount } => self.send_gold(to, amount),
} }
} }
} }
+13 -3
View File
@@ -97,14 +97,23 @@ impl Game {
if self.map.visible.len() != n { if self.map.visible.len() != n {
self.map.visible = vec![false; n]; self.map.visible = vec![false; n];
} }
// a master whose heart fell watches the rest of the match: the whole map is open to him
if self.spectating() {
self.map.visible = vec![true; n];
self.map.explored.iter_mut().for_each(|e| *e = true);
return;
}
let mut vis = vec![false; n]; let mut vis = vec![false; n];
let (w, h) = (mw() as i32, mh() as i32); let (w, h) = (mw() as i32, mh() as i32);
// the ground and the eyes of the master at this computer and of his allies (co-op partners,
// team mates); fog is local, so the local master decides
let allies: Vec<Owner> = std::iter::once(self.me).chain(Owner::MASTER_SIDES.iter().filter(|&&s| s != self.me_side && self.allied(self.me_side, s)).map(|&s| Owner::of_side(s))).collect();
let (cx, cy) = self.own_core_corner(); let (cx, cy) = self.own_core_corner();
let core_start = (cx - 3, cy - 3, cx + 5, cy + 5); let core_start = (cx - 3, cy - 3, cx + 5, cy + 5);
let in_core_start = |x: i32, y: i32| x >= core_start.0 && x <= core_start.2 && y >= core_start.1 && y <= core_start.3; let in_core_start = |x: i32, y: i32| x >= core_start.0 && x <= core_start.2 && y >= core_start.1 && y <= core_start.3;
// own ground and the ground around it, except straight outside the core's starting room // own ground and the ground around it, except straight outside the core's starting room
for i in 0..n { for i in 0..n {
if self.map.t[i].owner == self.me { if allies.contains(&self.map.t[i].owner) {
let (x, y) = xy(i); let (x, y) = xy(i);
vis[i] = true; vis[i] = true;
let in_start = in_core_start(x, y); let in_start = in_core_start(x, y);
@@ -128,8 +137,9 @@ impl Game {
} }
} }
// what the creatures and towers see along open ground (walls stop the view) // what the creatures and towers see along open ground (walls stop the view)
let mut eyes: Vec<(usize, i32)> = self.ents.iter().filter(|e| e.alive && !e.held && !e.hero && self.is_master(e.side) && !e.chained).map(|e| (e.tile(), SIGHT)).collect(); let mut eyes: Vec<(usize, i32)> = self.ents.iter().filter(|e| e.alive && !e.held && !e.hero && (e.side == self.me_side || self.allied(self.me_side, e.side)) && !e.chained).map(|e| (e.tile(), SIGHT)).collect();
eyes.extend(self.towers.iter().filter(|t| !t.hero).map(|t| (t.tile, t.range() as i32 + 1))); let solo = self.masters.is_empty();
eyes.extend(self.towers.iter().filter(|t| !t.hero && (solo || allies.contains(&self.map.t[t.tile].owner))).map(|t| (t.tile, t.range() as i32 + 1)));
let mut stamp = vec![0u32; n]; let mut stamp = vec![0u32; n];
let mut q = VecDeque::new(); let mut q = VecDeque::new();
for (sid, &(start, r)) in eyes.iter().enumerate() { for (sid, &(start, r)) in eyes.iter().enumerate() {
+33 -17
View File
@@ -617,6 +617,10 @@ pub struct Game {
#[serde(skip)] #[serde(skip)]
pub coop: bool, // co-op match: the playable master sides are on one side against the heroes pub coop: bool, // co-op match: the playable master sides are on one side against the heroes
#[serde(skip)] #[serde(skip)]
pub teams: Vec<(u8, u8)>, // (side, team) of a versus match; a side without an entry is its own team
#[serde(skip)]
pub out_sides: Vec<u8>, // masters of a match whose soul core fell (their creatures fight on)
#[serde(skip)]
pub marks: Vec<Mark>, // short-lived markers for commands given by the masters pub marks: Vec<Mark>, // short-lived markers for commands given by the masters
#[serde(skip)] #[serde(skip)]
pub hints: bool, // show hint messages (setting) pub hints: bool, // show hint messages (setting)
@@ -859,6 +863,8 @@ impl Game {
ai_sides: Vec::new(), ai_sides: Vec::new(),
ai_difficulty: 1, ai_difficulty: 1,
coop: false, coop: false,
teams: Vec::new(),
out_sides: Vec::new(),
marks: Vec::new(), marks: Vec::new(),
hint_t: hint_times(), hint_t: hint_times(),
reserve: 0.0, reserve: 0.0,
@@ -1367,15 +1373,8 @@ impl Game {
side == self.me_side || self.masters.iter().any(|m| m.side == side) side == self.me_side || self.masters.iter().any(|m| m.side == side)
} }
pub(crate) fn is_coop_ally_side(&self, side: u8) -> bool {
self.coop
&& Owner::MASTER_SIDES.contains(&self.local_side)
&& Owner::MASTER_SIDES.contains(&side)
&& (side == self.local_side || self.is_master(side) || self.ai_sides.iter().any(|&s| s == side))
}
pub(crate) fn is_hostile(&self, e: &Ent) -> bool { pub(crate) fn is_hostile(&self, e: &Ent) -> bool {
e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != self.me_side && !self.is_coop_ally_side(e.side()) e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != self.me_side && !self.allied(self.me_side, e.side())
} }
pub fn is_rival_side(&self, side: u8) -> bool { pub fn is_rival_side(&self, side: u8) -> bool {
@@ -1576,6 +1575,10 @@ impl Game {
} }
} }
for side in self.master_sides() { for side in self.master_sides() {
// a master whose heart fell has no household any more: no mana, no new creatures, no payday
if self.is_out(side) {
continue;
}
self.switch_to(side); self.switch_to(side);
self.economy(dt); self.economy(dt);
} }
@@ -1647,9 +1650,15 @@ impl Game {
self.update_rivals(dt); self.update_rivals(dt);
} }
self.update_dwarves(dt); self.update_dwarves(dt);
// fallen hearts of a match are shared state: every client marks them in the same step
self.update_fallen_masters();
// the end of the game and the fog are about the master at this computer // the end of the game and the fog are about the master at this computer
self.switch_to(local); self.switch_to(local);
self.check_end(); if self.netplay.is_some() {
self.check_match_end();
} else {
self.check_end();
}
self.update_fog(dt); self.update_fog(dt);
} }
@@ -2432,8 +2441,8 @@ impl Game {
let mut best = None; let mut best = None;
let mut bd = radius * radius; let mut bd = radius * radius;
for o in &self.ents { for o in &self.ents {
// every other side is an enemy; co-op master sides fight the heroes, not each other // every other side is an enemy; co-op partners and team mates fight the heroes, not each other
let coop_ally = self.is_coop_ally_side(o.side()); let coop_ally = self.allied(e.side(), o.side());
if !o.alive || o.held || o.side() == e.side() || coop_ally || o.captive != 0 || o.chained || o.invis > 0.0 { if !o.alive || o.held || o.side() == e.side() || coop_ally || o.captive != 0 || o.chained || o.invis > 0.0 {
continue; continue;
} }
@@ -2908,7 +2917,7 @@ impl Game {
for e in &self.ents { for e in &self.ents {
// any enemy of the player (heroes and the rivals' units) keeps ground from being claimed. // any enemy of the player (heroes and the rivals' units) keeps ground from being claimed.
// In co-op matches, allied master sides do not block the player's own ground claims. // In co-op matches, allied master sides do not block the player's own ground claims.
if !e.alive || e.held || e.captive != 0 || e.side() == 0 || (self.coop && Owner::MASTER_SIDES.contains(&e.side())) { if !e.alive || e.held || e.captive != 0 || e.side() == 0 || (self.coop && Owner::MASTER_SIDES.contains(&e.side())) || self.allied(0, e.side()) {
continue; continue;
} }
let (x, y) = (e.x as i32, e.y as i32); let (x, y) = (e.x as i32, e.y as i32);
@@ -3824,11 +3833,13 @@ impl Game {
let (fx, fy) = xy(f); let (fx, fy) = xy(f);
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5); let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
// the flag next to a rival master's core: attack the core // the flag next to a rival master's core: attack the core
let rival_near = self.rivals.iter().filter(|r| r.alive).find(|r| { let rival_near = self.rivals.iter().filter(|r| r.alive && !self.allied(e.side(), r.side)).find(|r| {
let (cx, cy) = r.centre(); let (cx, cy) = r.centre();
(fx - cx).powi(2) + (fy - cy).powi(2) <= 8.0 * 8.0 (fx - cx).powi(2) + (fy - cy).powi(2) <= 8.0 * 8.0
}); });
if let Some(side) = rival_near.map(|r| r.side) { // in a match the heart of an enemy master counts as well
let master_near = self.enemy_master_near(e.side(), fx, fy);
if let Some(side) = rival_near.map(|r| r.side).or(master_near) {
e.target = Some(side); e.target = Some(side);
if self.march_on(e, dt) { if self.march_on(e, dt) {
return; return;
@@ -4423,11 +4434,12 @@ impl Game {
let cur = e.tile(); let cur = e.tile();
if e.path.is_empty() && self.map.adj_core(cur) { if e.path.is_empty() && self.map.adj_core(cur) {
e.working = true; e.working = true;
let (cx, cy) = self.map.core; // the core next to it is the first master's (whoever is being processed in a match)
let (cx, cy) = self.core_of_side(0);
e.face = sgn(cx as f32 + 1.5 - e.x); e.face = sgn(cx as f32 + 1.5 - e.x);
if e.cd <= 0.0 { if e.cd <= 0.0 {
e.cd = st(e.kind).interval; e.cd = st(e.kind).interval;
self.core_hp -= e.atk(); self.damage_master_core(0, e.atk());
let fx = (e.x + cx as f32 + 1.5) * 0.5; let fx = (e.x + cx as f32 + 1.5) * 0.5;
let fy = (e.y + cy as f32 + 1.5) * 0.5; let fy = (e.y + cy as f32 + 1.5) * 0.5;
self.fx(2, fx, fy, 0.0, 0.0, 0.3, 0xff4040ff); self.fx(2, fx, fy, 0.0, 0.0, 0.3, 0xff4040ff);
@@ -5078,6 +5090,10 @@ impl Game {
e.side() != 1 e.side() != 1
} else if self.coop { } else if self.coop {
e.side() == 1 e.side() == 1
} else if !self.masters.is_empty() {
// a match: a tower belongs to the master whose ground it stands on and spares his allies
let owner = self.map.t.get(t.tile).map_or(0, |tl| crate::netplay::side_of_owner(tl.owner));
!self.allied(owner, e.side())
} else { } else {
e.side() != 0 e.side() != 0
}; };
@@ -6496,7 +6512,7 @@ mod tests {
g.coop = true; g.coop = true;
assert!(g.start_masters(&[0, 2, 3], 2), "a co-op match may keep an empty side as AI"); assert!(g.start_masters(&[0, 2, 3], 2), "a co-op match may keep an empty side as AI");
g.sync_ai_sides(&[3], 0); g.sync_ai_sides(&[3], 0);
assert!(g.is_coop_ally_side(3), "side 3 is an allied master seat, not a rival"); assert!(g.allied(g.me_side, 3), "side 3 is an allied master seat, not a rival");
g.ents.clear(); g.ents.clear();
let (cx, cy) = g.map.core; let (cx, cy) = g.map.core;
+193 -2
View File
@@ -30,6 +30,7 @@ pub struct SeatUi {
pub ready: bool, pub ready: bool,
pub connected: bool, pub connected: bool,
pub ai: bool, pub ai: bool,
pub team: u8, // 0..3 = A..D (versus)
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -39,6 +40,9 @@ pub struct RoomUi {
pub sides: Vec<u8>, pub sides: Vec<u8>,
pub started: bool, pub started: bool,
pub paused: bool, pub paused: bool,
pub mission: Option<usize>,
pub campaign: Option<String>, // the editor campaign of a co-op room
pub title: Option<String>, // and the title of its current mission
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -53,6 +57,7 @@ pub struct MatchUi {
pub sides: Vec<u8>, pub sides: Vec<u8>,
pub seats: Vec<SeatUi>, pub seats: Vec<SeatUi>,
pub ai: Vec<u8>, pub ai: Vec<u8>,
pub coop: bool, // the open room plays a co-op mission (no teams to choose)
pub rooms: Vec<RoomUi>, pub rooms: Vec<RoomUi>,
pub started: bool, pub started: bool,
pub paused: bool, pub paused: bool,
@@ -82,6 +87,9 @@ pub struct StartInfo {
// an editor campaign sent along by the host: `mission` is then one of its missions // an editor campaign sent along by the host: `mission` is then one of its missions
pub doc: Option<crate::campaign_doc::CampaignDoc>, pub doc: Option<crate::campaign_doc::CampaignDoc>,
pub editor: bool, // the start carried an editor campaign (even one that could not be read) pub editor: bool, // the start carried an editor campaign (even one that could not be read)
pub teams: Vec<(u8, u8)>, // (side, team) of a versus match
pub next: bool, // the next mission of a running editor campaign co-op match
pub carry: Vec<(u8, crate::campaign::Carry)>, // what every side brings into that mission
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -91,6 +99,7 @@ struct SeatInfo {
ready: bool, ready: bool,
connected: bool, connected: bool,
ai: bool, ai: bool,
team: u8,
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -103,6 +112,7 @@ struct RoomInfo {
started: bool, started: bool,
paused: bool, paused: bool,
missing: bool, missing: bool,
mission: Option<usize>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -119,6 +129,7 @@ pub struct MatchClient {
chat: Vec<(Option<u8>, String, String)>, chat: Vec<(Option<u8>, String, String)>,
chat_seen: usize, chat_seen: usize,
start: Option<StartInfo>, start: Option<StartInfo>,
doc: Option<crate::campaign_doc::CampaignDoc>, // the editor campaign of the running match
turns: Vec<(u32, Vec<Command>, Vec<u8>)>, turns: Vec<(u32, Vec<Command>, Vec<u8>)>,
checks: Vec<(Option<u8>, u32, u64)>, checks: Vec<(Option<u8>, u32, u64)>,
pub ping_ms: f32, pub ping_ms: f32,
@@ -142,6 +153,7 @@ impl MatchClient {
chat: Vec::new(), chat: Vec::new(),
chat_seen: 0, chat_seen: 0,
start: None, start: None,
doc: None,
turns: Vec::new(), turns: Vec::new(),
checks: Vec::new(), checks: Vec::new(),
ping_ms: 0.0, ping_ms: 0.0,
@@ -179,6 +191,7 @@ impl MatchClient {
self.code = None; self.code = None;
self.local_side = None; self.local_side = None;
self.start = None; self.start = None;
self.doc = None;
self.turns.clear(); self.turns.clear();
self.status = Some("offline".into()); self.status = Some("offline".into());
self.ping_ms = 0.0; self.ping_ms = 0.0;
@@ -272,6 +285,28 @@ impl MatchClient {
self.outbox.push_back(json!({"t":"set_ai","side":side,"ai":ai}).to_string()); self.outbox.push_back(json!({"t":"set_ai","side":side,"ai":ai}).to_string());
} }
// the team (0..3 = A..D) of the own seat, or - for the host - of a free or AI seat
pub fn set_team(&mut self, side: Option<u8>, team: u8) {
if !self.connected {
return;
}
let mut msg = json!({"t":"set_team","team":team});
if let Some(side) = side {
msg["side"] = json!(side);
}
self.outbox.push_back(msg.to_string());
}
// the host starts the next mission of the editor campaign for everybody: the run's seed and
// what every side carries along travel with it
pub fn next_mission(&mut self, mission: usize, seed: u32, carry: &[(u8, crate::campaign::Carry)]) {
if !self.connected {
return;
}
let carry = serde_json::to_value(carry).unwrap_or(Value::Array(Vec::new()));
self.outbox.push_back(json!({"t":"next","mission":mission,"seed":seed,"carry":carry}).to_string());
}
pub fn ready(&mut self, ready: bool) { pub fn ready(&mut self, ready: bool) {
if !self.connected { if !self.connected {
return; return;
@@ -358,6 +393,7 @@ impl MatchClient {
"rooms" => self.parse_rooms(&v), "rooms" => self.parse_rooms(&v),
"room" => self.parse_room(&v), "room" => self.parse_room(&v),
"start" => self.parse_start(&v), "start" => self.parse_start(&v),
"next" => self.parse_next(&v),
"turn" => self.parse_turn(&v), "turn" => self.parse_turn(&v),
"sum" => self.parse_sum(&v), "sum" => self.parse_sum(&v),
"chat" => { "chat" => {
@@ -378,6 +414,7 @@ impl MatchClient {
self.code = None; self.code = None;
self.local_side = None; self.local_side = None;
self.start = None; self.start = None;
self.doc = None;
self.turns.clear(); self.turns.clear();
self.chat.clear(); self.chat.clear();
self.chat_seen = 0; self.chat_seen = 0;
@@ -420,6 +457,9 @@ impl MatchClient {
sides: array_bytes(r.get("sides")), sides: array_bytes(r.get("sides")),
started: r.get("started").and_then(|x| x.as_bool()).unwrap_or(false), started: r.get("started").and_then(|x| x.as_bool()).unwrap_or(false),
paused: r.get("paused").and_then(|x| x.as_bool()).unwrap_or(false), paused: r.get("paused").and_then(|x| x.as_bool()).unwrap_or(false),
mission: r.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize),
campaign: r.get("campaign").and_then(|x| x.as_str()).map(|x| x.to_string()),
title: r.get("title").and_then(|x| x.as_str()).map(|x| x.to_string()),
}) })
.collect(); .collect();
} }
@@ -451,6 +491,9 @@ impl MatchClient {
if let Some(x) = v.get("missing").and_then(|x| x.as_bool()) { if let Some(x) = v.get("missing").and_then(|x| x.as_bool()) {
room.missing = x; room.missing = x;
} }
if v.get("mission").is_some() {
room.mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
}
if let Some(why) = v.get("why").and_then(|x| x.as_str()) { if let Some(why) = v.get("why").and_then(|x| x.as_str()) {
self.status = Some(why.to_string()); self.status = Some(why.to_string());
} }
@@ -471,6 +514,8 @@ impl MatchClient {
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize); let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
let editor = v.get("doc").map_or(false, |d| d.is_object()); let editor = v.get("doc").map_or(false, |d| d.is_object());
let doc = v.get("doc").filter(|d| d.is_object()).and_then(|d| <crate::campaign_doc::CampaignDoc as serde::Deserialize>::deserialize(d).ok()); let doc = v.get("doc").filter(|d| d.is_object()).and_then(|d| <crate::campaign_doc::CampaignDoc as serde::Deserialize>::deserialize(d).ok());
self.doc = doc.clone();
let teams = teams_from(&sides, v.get("teams"));
let mut ai = array_bytes(v.get("ai")); let mut ai = array_bytes(v.get("ai"));
if ai.is_empty() { if ai.is_empty() {
if let Some(r) = self.room.as_ref() { if let Some(r) = self.room.as_ref() {
@@ -489,11 +534,41 @@ impl MatchClient {
} }
r.ai = ai.clone(); r.ai = ai.clone();
} }
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor }); self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor, teams, next: false, carry: Vec::new() });
self.error = None; self.error = None;
self.status = None; self.status = None;
} }
// the next mission of the running editor campaign: built from the campaign of the start
fn parse_next(&mut self, v: &Value) {
let Some(doc) = self.doc.clone() else { return };
let Some(mission) = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize) else { return };
let Some(m) = doc.missions.get(mission) else { return };
let (map_w, map_h) = m.size;
let seed = v.get("seed").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
let sides = array_bytes(v.get("sides"));
let ai = array_bytes(v.get("ai"));
let teams = teams_from(&sides, v.get("teams"));
let carry: Vec<(u8, crate::campaign::Carry)> = v.get("carry").cloned().and_then(|c| serde_json::from_value(c).ok()).unwrap_or_default();
// turns that came before belong to the mission that ended
self.turns.clear();
let local_side = self.room.as_ref().and_then(|r| r.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side)).or(self.local_side);
self.local_side = local_side;
if let Some(r) = self.room.as_mut() {
r.turn = 0;
r.paused = false;
r.missing = false;
r.mission = Some(mission);
if !sides.is_empty() {
r.sides = sides.clone();
}
r.ai = ai.clone();
}
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission: Some(mission), ai, doc: Some(doc), editor: true, teams, next: true, carry });
self.error = None;
}
fn parse_turn(&mut self, v: &Value) { fn parse_turn(&mut self, v: &Value) {
let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32; let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
if let Some(r) = self.room.as_mut() { if let Some(r) = self.room.as_mut() {
@@ -534,7 +609,8 @@ impl MatchClient {
is_host, is_host,
sides: room.sides.clone(), sides: room.sides.clone(),
ai: room.ai.clone(), ai: room.ai.clone(),
seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai }).collect(), coop: room.mission.is_some(),
seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai, team: s.team }).collect(),
rooms: self.rooms.clone(), rooms: self.rooms.clone(),
started: room.started, started: room.started,
paused: room.paused, paused: room.paused,
@@ -547,12 +623,23 @@ impl MatchClient {
} }
} }
// every seat starts as a team of its own (the relay does the same)
fn default_team(side: u8) -> u8 {
crate::map::Owner::MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8
}
fn array_bytes(v: Option<&Value>) -> Vec<u8> { fn array_bytes(v: Option<&Value>) -> Vec<u8> {
v.and_then(|x| x.as_array()) v.and_then(|x| x.as_array())
.map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect()) .map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect())
.unwrap_or_default() .unwrap_or_default()
} }
// (side, team) pairs from the teams list that goes along with `sides`
fn teams_from(sides: &[u8], v: Option<&Value>) -> Vec<(u8, u8)> {
let teams = array_bytes(v);
sides.iter().copied().zip(teams).collect()
}
fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> { fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
v.and_then(|x| x.as_array()) v.and_then(|x| x.as_array())
.map(|arr| { .map(|arr| {
@@ -563,6 +650,7 @@ fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
ready: s.get("ready").and_then(|x| x.as_bool()).unwrap_or(false), ready: s.get("ready").and_then(|x| x.as_bool()).unwrap_or(false),
connected: s.get("connected").and_then(|x| x.as_bool()).unwrap_or(false), connected: s.get("connected").and_then(|x| x.as_bool()).unwrap_or(false),
ai: s.get("ai").and_then(|x| x.as_bool()).unwrap_or(false), ai: s.get("ai").and_then(|x| x.as_bool()).unwrap_or(false),
team: s.get("team").and_then(|x| x.as_u64()).unwrap_or_else(|| default_team(s.get("side").and_then(|x| x.as_u64()).unwrap_or(0) as u8) as u64) as u8,
}) })
.collect() .collect()
}) })
@@ -704,9 +792,112 @@ mod tests {
g.coop = true; g.coop = true;
assert!(g.start_match(&info.sides, info.local_side.unwrap()), "the masters fit the map"); assert!(g.start_match(&info.sides, info.local_side.unwrap()), "the masters fit the map");
g.sync_ai_sides(&info.ai, info.difficulty); g.sync_ai_sides(&info.ai, info.difficulty);
crate::campaign::apply_coop_carry(&mut g, &info.carry);
g g
} }
#[test]
fn two_clients_play_two_editor_missions_in_a_row_with_carry() {
use crate::game::EK;
let doc = coop_doc();
let mut clients = [MatchClient::new(), MatchClient::new()];
let room = r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","ready":true,"connected":true},{"side":2,"name":"green","ready":true,"connected":true}],"ai":[],"turn":0,"started":false,"mission":0}"#;
let start = json!({"t":"start","seed":99,"map_w":80,"map_h":80,"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":0,"ai":[],"doc":serde_json::to_value(&doc).unwrap()}).to_string();
let mut games = Vec::new();
for (c, name) in clients.iter_mut().zip(["red", "green"]) {
c.on_connect(name);
c.outbox.clear();
c.feed(room);
c.feed(&start);
let info = c.take_start().expect("start info");
assert!(!info.next && info.carry.is_empty());
games.push(coop_game(&info));
}
for t in 0..60u32 {
for g in games.iter_mut() {
g.receive_turn(&[]);
}
if t % 20 == 19 {
assert_eq!(games[0].checksum(), games[1].checksum(), "mission 1, turn {t}");
}
}
// green raised a strong creature, red has a weak one; then the goal is reached on both
for g in games.iter_mut() {
let (cx, cy) = g.core_of_side(2);
let local = g.local_side;
g.switch_to(2);
let ogre = g.spawn(EK::Ogre, false, cx as f32 + 4.5, cy as f32 + 1.5, 6);
g.switch_to(0);
let (rx, ry) = g.core_of_side(0);
g.spawn(EK::Kobold, false, rx as f32 + 4.5, ry as f32 + 1.5, 1);
g.switch_to(local);
assert!(g.ents.iter().any(|e| e.id == ogre && e.side == 2));
g.state = 1;
}
let (next, _) = doc.next_mission_choice(0, |goal| games[0].match_goal_done(goal));
assert_eq!(next, Some(1), "the run goes on with the next mission");
let carry = crate::campaign::coop_carry_from(&games[0]);
assert_eq!(serde_json::to_string(&carry).unwrap(), serde_json::to_string(&crate::campaign::coop_carry_from(&games[1])).unwrap(), "every client sees the same carry");
let green_carry = &carry.iter().find(|c| c.0 == 2).unwrap().1;
assert!(green_carry.creatures.iter().any(|c| c.0 == EK::Ogre && c.1 == 6), "green takes his ogre along");
assert_eq!(green_carry.gold, (games[0].gold_of(2) * crate::campaign::CARRY_GOLD).floor());
// the host asks the relay for the next mission; the relay hands it to everybody
clients[0].next_mission(1, 99, &carry);
let req = pop(&mut clients[0]);
assert_eq!(req["t"].as_str(), Some("next"));
assert_eq!(req["mission"].as_u64(), Some(1));
let next = json!({"t":"next","seed":req["seed"],"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":1,"ai":[],"carry":req["carry"]}).to_string();
let mut games2 = Vec::new();
for c in clients.iter_mut() {
c.feed(r#"{"t":"turn","turn":61,"cmds":[]}"#); // a late turn of the old mission
c.feed(&next);
let info = c.take_start().expect("the next mission starts");
assert!(info.next && info.editor && info.doc.is_some());
assert_eq!(info.mission, Some(1));
assert_eq!(info.carry.len(), 2);
assert!(c.take_turns().is_empty(), "turns of the old mission are dropped");
games2.push(coop_game(&info));
}
let (a, b) = (&games2[0], &games2[1]);
assert_eq!(a.checksum(), b.checksum(), "mission 2: the same start with the carry");
assert!(a.ents.iter().any(|e| e.alive && e.side == 2 && e.kind == EK::Ogre && e.level == 6), "the ogre came along with green");
let mut games2 = games2;
let (cx, cy) = games2[0].core_of_side(0);
for t in 0..80u32 {
let cmds = if t % 10 == 0 { vec![Command { side: 0, seq: t, act: Act::Dig(Rect::new(cx + 5, cy + (t / 10) as i32 - 3, cx + 6, cy + (t / 10) as i32 - 2)) }] } else { Vec::new() };
for g in games2.iter_mut() {
g.receive_turn(&cmds);
}
if t % 10 == 9 {
assert_eq!(games2[0].checksum(), games2[1].checksum(), "mission 2: identical up to turn {t}");
}
}
}
#[test]
fn the_bridge_sends_teams_and_reads_them_back() {
let mut client = MatchClient::new();
client.on_connect("red");
pop(&mut client); // rooms
client.set_team(None, 1);
let req = pop(&mut client);
assert_eq!((req["t"].as_str(), req["team"].as_u64(), req.get("side")), (Some("set_team"), Some(1), None));
client.set_team(Some(3), 2);
assert_eq!(pop(&mut client)["side"].as_u64(), Some(3));
client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","connected":true,"team":1},{"side":2,"name":"","connected":false,"team":1}],"mission":null}"#);
let ui = client.ui();
assert!(!ui.coop);
assert_eq!(ui.seats.iter().map(|s| s.team).collect::<Vec<_>>(), vec![1, 1]);
client.feed(r#"{"t":"rooms","rooms":[{"code":"ABC123","players":1,"sides":[0,2],"mission":0,"editor":true,"campaign":"Deep Run","title":"First"}]}"#);
let ui = client.ui();
assert_eq!(ui.rooms[0].campaign.as_deref(), Some("Deep Run"));
assert_eq!(ui.rooms[0].title.as_deref(), Some("First"));
client.feed(r#"{"t":"start","seed":7,"sides":[0,2],"teams":[1,1]}"#);
let start = client.take_start().unwrap();
assert_eq!(start.teams, vec![(0, 1), (2, 1)]);
}
#[test] #[test]
fn two_clients_play_the_same_editor_campaign_mission_in_coop() { fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
let doc = coop_doc(); let doc = coop_doc();
+86 -3
View File
@@ -222,6 +222,53 @@ fn store_progress(p: &campaign::Progress) {
platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default()); platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default());
} }
// progress of editor campaigns played in co-op, kept by the host: campaign name -> progress
const COOP_PROGRESS_KEY: &str = "dc.coop_editor_progress";
fn coop_progress_all() -> std::collections::BTreeMap<String, campaign_doc::CampaignProgress> {
platform::storage_get(COOP_PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
fn store_coop_progress(name: &str, p: &campaign_doc::CampaignProgress) {
let mut all = coop_progress_all();
all.insert(name.to_string(), p.clone());
platform::storage_set(COOP_PROGRESS_KEY, &serde_json::to_string(&all).unwrap_or_default());
}
// a co-op run through an editor campaign: the mission being played, the run's seed, what came
// along into it, and - once it is over - where the host goes next and what everybody takes along
struct CoopRun {
doc: campaign_doc::CampaignDoc,
mission: usize,
run_seed: u32,
carry_in: Vec<(u8, campaign::Carry)>,
next: Option<usize>,
carry_out: Vec<(u8, campaign::Carry)>,
sent: bool, // the host asked the relay for the next mission
}
impl CoopRun {
// the mission is over: the host decides where the run goes on (the branch of the reached goal,
// or the same mission again after a defeat) and stores the progress
fn finish(&mut self, g: &Game) {
let won = g.state == 1;
if won {
let (next, choice) = self.doc.next_mission_choice(self.mission, |goal| g.match_goal_done(goal));
self.next = next.filter(|n| self.doc.missions.get(*n).map_or(false, campaign::is_editor_coop_mission));
self.carry_out = campaign::coop_carry_from(g);
if g.local_side == 0 {
let mut p = coop_progress_all().remove(&self.doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&self.doc));
p.run_seed = self.run_seed;
p.mark_done(self.mission, self.doc.missions.len(), choice);
store_coop_progress(&self.doc.name, &p);
}
} else {
self.next = Some(self.mission);
self.carry_out = self.carry_in.clone();
}
}
}
// login, account and standalone actions of the menu // login, account and standalone actions of the menu
fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) { fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) {
match a { match a {
@@ -297,6 +344,7 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
} }
MenuAction::MatchTakeSide(side) => app.client.take_side(side), MenuAction::MatchTakeSide(side) => app.client.take_side(side),
MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai), 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),
MenuAction::MatchReady(ready) => app.client.ready(ready), MenuAction::MatchReady(ready) => app.client.ready(ready),
MenuAction::MatchStart => app.client.start(), MenuAction::MatchStart => app.client.start(),
MenuAction::MatchResume => app.client.resume(), MenuAction::MatchResume => app.client.resume(),
@@ -450,6 +498,7 @@ struct App {
match_mode: bool, match_mode: bool,
coop: bool, coop: bool,
coop_editor: bool, // the co-op match plays a mission of an editor campaign coop_editor: bool, // the co-op match plays a mission of an editor campaign
coop_run: Option<CoopRun>, // ... and this is where the run through that campaign stands
fps: f32, fps: f32,
renderer: String, renderer: String,
frame_ms: f32, frame_ms: f32,
@@ -586,6 +635,8 @@ impl App {
} }
game.ai_difficulty = info.difficulty; game.ai_difficulty = info.difficulty;
game.sync_ai_sides(&info.ai, info.difficulty); game.sync_ai_sides(&info.ai, info.difficulty);
// what the masters bring from the last mission of an editor campaign run (same on every client)
campaign::apply_coop_carry(&mut game, &info.carry);
apply_game_settings(&mut game, &self.settings); apply_game_settings(&mut game, &self.settings);
self.game = game; self.game = game;
let (cx, cy) = self.game.map.core; let (cx, cy) = self.game.map.core;
@@ -598,6 +649,7 @@ impl App {
fn start_match_game(&mut self, info: StartInfo) -> bool { fn start_match_game(&mut self, info: StartInfo) -> bool {
self.coop_editor = false; self.coop_editor = false;
self.coop_run = None;
if !info.sides.contains(&0) { if !info.sides.contains(&0) {
return false; return false;
} }
@@ -614,7 +666,16 @@ impl App {
return false; return false;
} }
self.coop_editor = true; self.coop_editor = true;
self.recorded = false;
self.pending_briefing = briefing_of(doc, mission); self.pending_briefing = briefing_of(doc, mission);
// the host keeps the progress of the run (a new run starts with the room's seed)
if !info.next && info.local_side == Some(0) {
let mut p = coop_progress_all().remove(&doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(doc));
p.ensure(doc.missions.len());
p.run_seed = info.seed;
store_coop_progress(&doc.name, &p);
}
self.coop_run = Some(CoopRun { doc: doc.clone(), mission, run_seed: info.seed, carry_in: info.carry.clone(), next: None, carry_out: Vec::new(), sent: false });
return true; return true;
} }
if let Some(mission) = info.mission { if let Some(mission) = info.mission {
@@ -633,6 +694,7 @@ impl App {
return false; return false;
} }
self.game.coop = false; self.game.coop = false;
self.game.set_teams(&info.teams);
self.game.ai_difficulty = info.difficulty; self.game.ai_difficulty = info.difficulty;
self.game.sync_ai_sides(&info.ai, info.difficulty); self.game.sync_ai_sides(&info.ai, info.difficulty);
apply_game_settings(&mut self.game, &self.settings); apply_game_settings(&mut self.game, &self.settings);
@@ -695,7 +757,7 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1; let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
// behind the title menu: the endless bridge battle // behind the title menu: the endless bridge battle
let (game, cam) = Game::new_demo(seed); 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, 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, 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(); 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); 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 }; 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 };
@@ -852,6 +914,8 @@ async fn main() {
// ---------------- multiplayer relay: receive messages, send queued requests, update the menu state // ---------------- multiplayer relay: receive messages, send queued requests, update the menu state
app.poll_match(now); app.poll_match(now);
menu.in_match = app.match_mode || app.game.in_match(); menu.in_match = app.match_mode || app.game.in_match();
menu.gold_allies = app.game.gold_allies();
ui.coop_end = app.coop_run.as_ref().filter(|_| app.coop_editor).map(|r| (app.game.local_side == 0, r.next.is_some(), r.sent));
if matches!(menu.page, Some(Page::Match { .. })) { if matches!(menu.page, Some(Page::Match { .. })) {
menu.set_match_ui(app.client.ui()); menu.set_match_ui(app.client.ui());
} }
@@ -1215,6 +1279,7 @@ async fn main() {
match menu.click(ux, uy, &mut app.settings, &cloud) { match menu.click(ux, uy, &mut app.settings, &cloud) {
MenuAction::SettingsChanged => settings_changed = true, MenuAction::SettingsChanged => settings_changed = true,
MenuAction::Resume => {} MenuAction::Resume => {}
MenuAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }),
MenuAction::NewGame => { MenuAction::NewGame => {
app.seed = next_seed(app.seed); app.seed = next_seed(app.seed);
(app.game, app.cam) = new_game(app.seed, &app.settings); (app.game, app.cam) = new_game(app.seed, &app.settings);
@@ -1398,6 +1463,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
| MenuAction::MatchCoopCreate(..) | MenuAction::MatchCoopCreate(..)
| MenuAction::MatchTakeSide(..) | MenuAction::MatchTakeSide(..)
| MenuAction::MatchSetAi(..) | MenuAction::MatchSetAi(..)
| MenuAction::MatchSetTeam(..)
| MenuAction::MatchReady(..) | MenuAction::MatchReady(..)
| MenuAction::MatchStart | MenuAction::MatchStart
| MenuAction::MatchResume | MenuAction::MatchResume
@@ -1447,7 +1513,18 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
} }
// end of a mission: next mission (won) or try again (lost) // end of a mission: next mission (won) or try again (lost)
UiAction::EndNext | UiAction::EndCampaign if app.editor_testing && app.editor_test_only => back_to_editor(&mut app, &mut menu, &mut r3d), UiAction::EndNext | UiAction::EndCampaign if app.editor_testing && app.editor_test_only => back_to_editor(&mut app, &mut menu, &mut r3d),
// a co-op mission of an editor campaign is over: the match ends, back to the lobby // 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 let Some(next) = run.next {
app.client.next_mission(next, run.run_seed, &run.carry_out);
run.sent = true;
}
}
}
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 => { UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => {
app.leave_match(); app.leave_match();
app.seed = next_seed(app.seed); app.seed = next_seed(app.seed);
@@ -1521,6 +1598,7 @@ app.pending_briefing = briefing_of(&campaign, i);
} }
} }
UiAction::OpenMenu => menu.page = Some(Page::Pause), UiAction::OpenMenu => menu.page = Some(Page::Pause),
UiAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }),
UiAction::Locked => { UiAction::Locked => {
let locked = ui.last_locked.unwrap_or(usize::MAX); let locked = ui.last_locked.unwrap_or(usize::MAX);
app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]); app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]);
@@ -1670,7 +1748,12 @@ app.pending_briefing = briefing_of(&campaign, i);
// a won mission goes into the campaign progress (with the carry-over); a co-op mission of an // a won mission goes into the campaign progress (with the carry-over); a co-op mission of an
// editor campaign keeps no progress (its index is not a built-in mission) // editor campaign keeps no progress (its index is not a built-in mission)
if app.coop_editor { if app.coop_editor {
app.recorded = app.game.state != 0; if app.game.state != 0 && !app.recorded {
app.recorded = true;
if let Some(run) = app.coop_run.as_mut() {
run.finish(&app.game);
}
}
} else if app.custom_campaign.is_none() { } else if app.custom_campaign.is_none() {
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) { if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
if app.game.state != 0 { if app.game.state != 0 {
+69 -2
View File
@@ -281,6 +281,9 @@ enum Btn {
MatchCoopSource, // switch between the built-in campaign and the editor campaign MatchCoopSource, // switch between the built-in campaign and the editor campaign
MatchTake(usize), MatchTake(usize),
MatchAi(usize), MatchAi(usize),
MatchTeam(usize), // next team for that seat
GoldTo, // pause menu: the next ally to receive gold
Gold(u32), // pause menu: send that much gold (GOLD_ALL: everything)
MatchReady, MatchReady,
MatchStart, MatchStart,
MatchResume, MatchResume,
@@ -396,6 +399,8 @@ pub enum MenuAction {
MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc` MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc`
MatchTakeSide(u8), MatchTakeSide(u8),
MatchSetAi(u8, bool), MatchSetAi(u8, bool),
MatchSetTeam(u8, u8, bool), // side, team, the own seat
SendGold(u8, u32), // ally side, amount
MatchReady(bool), MatchReady(bool),
MatchStart, MatchStart,
MatchResume, MatchResume,
@@ -419,6 +424,8 @@ pub struct Menu {
pub coop_editor: bool, // the Match page offers the editor campaign instead of the built-in one pub coop_editor: bool, // the Match page offers the editor campaign instead of the built-in one
pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op
pub in_match: bool, // a match is being played; the pause menu changes its shape pub in_match: bool, // a match is being played; the pause menu changes its shape
pub gold_allies: Vec<u8>, // allies of the local master that can get gold (pause menu)
pub gold_to: usize, // the one chosen among them
pub editor_test: bool, // a mission of the editor is being tested: back to the editor pub editor_test: bool, // a mission of the editor is being tested: back to the editor
pub custom_briefing: (String, String), // title and text of an editor mission's briefing pub custom_briefing: (String, String), // title and text of an editor mission's briefing
pub campaign_doc: CampaignDoc, pub campaign_doc: CampaignDoc,
@@ -458,6 +465,8 @@ impl Menu {
coop_editor: false, coop_editor: false,
coop_editor_mission: 0, coop_editor_mission: 0,
in_match: false, in_match: false,
gold_allies: Vec::new(),
gold_to: 0,
editor_test: false, editor_test: false,
custom_briefing: (String::new(), String::new()), custom_briefing: (String::new(), String::new()),
campaign_doc: CampaignDoc::default(), campaign_doc: CampaignDoc::default(),
@@ -791,6 +800,18 @@ impl Menu {
Some(s) => MenuAction::MatchTakeSide(s.side), Some(s) => MenuAction::MatchTakeSide(s.side),
None => MenuAction::Consumed, None => MenuAction::Consumed,
}, },
Some(Btn::MatchTeam(i)) => match self.match_ui.seats.get(i) {
Some(s) => MenuAction::MatchSetTeam(s.side, (s.team + 1) % 4, Some(s.side) == self.match_ui.local_side),
None => MenuAction::Consumed,
},
Some(Btn::GoldTo) => {
self.gold_to = (self.gold_to + 1) % self.gold_allies.len().max(1);
MenuAction::Consumed
}
Some(Btn::Gold(amount)) => match self.gold_allies.get(self.gold_to.min(self.gold_allies.len().saturating_sub(1))) {
Some(&to) => MenuAction::SendGold(to, amount),
None => MenuAction::Consumed,
},
Some(Btn::MatchAi(i)) => match self.match_ui.seats.get(i) { Some(Btn::MatchAi(i)) => match self.match_ui.seats.get(i) {
Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai), Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai),
_ => MenuAction::Consumed, _ => MenuAction::Consumed,
@@ -1156,13 +1177,32 @@ impl Menu {
(Btn::MainMenu, tr("menu.main")), (Btn::MainMenu, tr("menu.main")),
]); ]);
} }
let cols = 32; // a match with allies: gold for one of them (the ally button switches between them)
let rows = items.len() as i32 * 3 + 4; let gold = self.in_match && !self.gold_allies.is_empty();
let cols = if gold { 40 } else { 32 };
let rows = items.len() as i32 * 3 + 4 + if gold { 6 } else { 0 };
let x = grid_center(sw, cols as f32 * CW()); let x = grid_center(sw, cols as f32 * CW());
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.paused"), FRAME_HI); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.paused"), FRAME_HI);
let w = (cols - 6) as f32 * CW(); let w = (cols - 6) as f32 * CW();
self.column(gfx, x + CW() * 3.0, y + CH() * 2.0, w, &items); self.column(gfx, x + CW() * 3.0, y + CH() * 2.0, w, &items);
if gold {
let (mx, my) = mouse();
self.gold_to = self.gold_to.min(self.gold_allies.len() - 1);
let to = self.gold_allies[self.gold_to];
let gy = y + CH() * (2.0 + items.len() as f32 * 3.0);
let label = trf("gold.send_to", &[tr(&format!("net.side{to}"))]);
let r = Rect::new(x + CW() * 3.0, gy, w, CH() * 2.0);
button(gfx, r, &label, r.contains(vec2(mx, my)), side_color(Some(to)));
self.buttons.push((r, Btn::GoldTo));
let bw = (w - CW() * 3.0) / 4.0;
for (k, amount) in crate::netplay::GOLD_AMOUNTS.iter().enumerate() {
let label = if *amount == crate::netplay::GOLD_ALL { tr("gold.all") } else { amount.to_string() };
let r = Rect::new(x + CW() * 3.0 + k as f32 * (bw + CW()), gy + CH() * 2.6, bw, CH() * 2.0);
button(gfx, r, &label, r.contains(vec2(mx, my)), GOLD);
self.buttons.push((r, Btn::Gold(*amount)));
}
}
let ver = tr("menu.version"); let ver = tr("menu.version");
let line = format!("{} {}", ver, version()); let line = format!("{} {}", ver, version());
gfx.text(&line, x + (cols as f32 * CW() - gfx.measure(&line, SMALL())) * 0.5, y + (rows as f32 - 1.7) * CH(), SMALL(), DIM); gfx.text(&line, x + (cols as f32 * CW() - gfx.measure(&line, SMALL())) * 0.5, y + (rows as f32 - 1.7) * CH(), SMALL(), DIM);
@@ -1970,6 +2010,21 @@ impl Menu {
let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0); let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0);
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchJoinRoom(k))); self.buttons.push((r, Btn::MatchJoinRoom(k)));
// what the room plays: an editor campaign (name and mission), a co-op mission or versus
let what = match (&room.campaign, room.mission) {
(Some(name), _) => {
let name = if name.trim().is_empty() { "?".to_string() } else { name.clone() };
match &room.title {
Some(t) if !t.trim().is_empty() => format!("{name}: {t}"),
_ => name,
}
}
(None, Some(m)) => missions().get(m).map_or_else(|| tr("net.coop_create"), |d| tr(&format!("camp.{}.title", d.key))),
(None, None) => tr("net.versus"),
};
let maxc = ((w - 25.0 * CW()) / (GW * SMALL())).max(4.0) as usize;
let what: String = what.chars().take(maxc).collect();
gfx.text(&what, ix + 25.0 * CW(), yy + (CH() * 2.0 - GH * SMALL()) * 0.5, SMALL(), if room.campaign.is_some() { GOLD } else { DIM });
yy += CH() * 2.2; yy += CH() * 2.2;
} }
} else if ui.rooms.is_empty() && !ui.started { } else if ui.rooms.is_empty() && !ui.started {
@@ -1999,6 +2054,18 @@ impl Menu {
let ready = if s.ready || s.ai { "✓" } else { "—" }; let ready = if s.ready || s.ai { "✓" } else { "—" };
let line = format!("{} {} {} {} {}", side_label, name, status, ready, if s.side == 0 { tr("net.host") } else { String::new() }); let line = format!("{} {} {} {} {}", side_label, name, status, ready, if s.side == 0 { tr("net.host") } else { String::new() });
gfx.text(&line, ix, yy, SMALL(), if s.connected || s.ai { TXT } else { DIM }); gfx.text(&line, ix, yy, SMALL(), if s.connected || s.ai { TXT } else { DIM });
// versus: every seat chooses its team (the host also for free and AI seats)
if !ui.coop {
let team = trf("net.team", &[((b'A' + s.team.min(3)) as char).to_string()]);
let r = Rect::new(ix + 49.0 * CW(), yy, 10.0 * CW(), CH() * 2.0);
let may = !ui.started && (is_local || (ui.is_host && !s.connected));
if may {
button(gfx, r, &team, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchTeam(k)));
} else {
gfx.text(&team, r.x + CW(), yy + CH() * 0.5, SMALL(), DIM);
}
}
if !ui.started && ui.local_side.is_some() && !s.connected { if !ui.started && ui.local_side.is_some() && !s.connected {
if ui.is_host && s.side != 0 { if ui.is_host && s.side != 0 {
if s.ai { if s.ai {
+446 -1
View File
@@ -6,6 +6,7 @@
use crate::commands::Command; use crate::commands::Command;
use crate::game::Game; use crate::game::Game;
use crate::map::{Kind, Owner};
// one server turn of 100 ms: five fixed simulation steps // one server turn of 100 ms: five fixed simulation steps
pub const STEPS_PER_TURN: usize = 5; pub const STEPS_PER_TURN: usize = 5;
@@ -46,7 +47,281 @@ fn mixdbg(h: &mut u64, v: impl std::fmt::Debug) {
} }
} }
// the side whose ground a tile of `owner` is (unowned ground counts as the first player's)
pub fn side_of_owner(owner: Owner) -> u8 {
match owner {
Owner::Hero => 1,
Owner::Rival1 => 2,
Owner::Rival2 => 3,
Owner::Rival3 => 4,
Owner::Player | Owner::None => 0,
}
}
// gold amounts a master can send to an ally; `GOLD_ALL` sends everything he has
pub const GOLD_ALL: u32 = u32::MAX;
pub const GOLD_AMOUNTS: [u32; 4] = [100, 500, 1000, GOLD_ALL];
impl Game { impl Game {
// ------------------------------------------------------------ teams and allies
// the team of a master seat in a versus match (sides without an entry are a team of their own)
pub fn team_of(&self, side: u8) -> u8 {
self.teams.iter().find(|t| t.0 == side).map_or(10 + side, |t| t.1)
}
// a master seat of the match: a human master or an AI seat
pub fn is_seat_side(&self, side: u8) -> bool {
Owner::MASTER_SIDES.contains(&side) && (self.is_master(side) || self.ai_sides.contains(&side))
}
// sides that never fight each other: the same side, co-op partners and team mates. Only shared
// state goes in, so every client of a match decides the same.
pub fn allied(&self, a: u8, b: u8) -> bool {
if a == b {
return true;
}
if !self.is_seat_side(a) || !self.is_seat_side(b) {
return false;
}
self.coop || self.team_of(a) == self.team_of(b)
}
// the teams of a versus match: (side, team) for the master seats
pub fn set_teams(&mut self, teams: &[(u8, u8)]) {
self.teams = teams.iter().copied().filter(|(s, _)| Owner::MASTER_SIDES.contains(s)).collect();
}
// ------------------------------------------------------------ fallen hearts
pub fn is_out(&self, side: u8) -> bool {
self.out_sides.contains(&side)
}
// the master at this computer lost his heart: he watches the rest of the match
pub fn spectating(&self) -> bool {
self.netplay.is_some() && self.is_out(self.local_side)
}
fn master_state(&self, side: u8) -> Option<&crate::game::MasterState> {
self.masters.iter().find(|m| m.side == side)
}
pub fn core_hp_of(&self, side: u8) -> f32 {
if side == self.me_side {
self.core_hp
} else {
self.master_state(side).map_or(0.0, |m| m.core_hp)
}
}
pub fn core_of_side(&self, side: u8) -> (i32, i32) {
if side == self.me_side {
self.map.core
} else {
self.master_state(side).map_or(self.map.core, |m| m.core)
}
}
pub fn gold_of(&self, side: u8) -> f32 {
if side == self.me_side {
self.gold
} else {
self.master_state(side).map_or(0.0, |m| m.gold)
}
}
// damage to the soul core of a master (whichever master is being processed)
pub fn damage_master_core(&mut self, side: u8, dmg: f32) {
if side == self.me_side {
self.core_hp -= dmg;
} else if let Some(m) = self.masters.iter_mut().find(|m| m.side == side) {
m.core_hp -= dmg;
}
}
// the heart of a master of the match that is no ally of `side`, near (x, y)
pub(crate) fn enemy_master_near(&self, side: u8, x: f32, y: f32) -> Option<u8> {
if self.masters.is_empty() {
return None;
}
self.master_sides().into_iter().filter(|&s| s != side && !self.is_out(s) && !self.allied(side, s)).find(|&s| {
let (cx, cy) = self.core_of_side(s);
(x - cx as f32 - 1.5).powi(2) + (y - cy as f32 - 1.5).powi(2) <= 8.0 * 8.0
})
}
// a message and a sound for the player at this computer, whichever master is being processed
fn tell_local(&mut self, key: &'static str, args: Vec<String>, sfx: Option<crate::game::Sfx>) {
let me = self.me_side;
let local = self.local_side;
self.switch_to(local);
self.msg(key, args);
if let Some(sfx) = sfx {
self.sfx_ui(sfx);
}
self.switch_to(me);
}
// a master of a match whose core has no strength left is out. His creatures fight on without a
// heart until they fall; he keeps watching. Runs in the shared part of the step.
pub(crate) fn update_fallen_masters(&mut self) {
if self.netplay.is_none() {
return;
}
for side in self.master_sides() {
if self.is_out(side) || self.core_hp_of(side) > 0.0 {
continue;
}
self.out_sides.push(side);
self.master_fell(side);
}
}
fn master_fell(&mut self, side: u8) {
let me = self.me_side;
self.switch_to(side);
self.core_hp = 0.0;
self.flag = None;
// what he held in his hand is put down beside the broken heart
let (cx, cy) = self.map.core;
let hand = std::mem::take(&mut self.hand);
for (k, id) in hand.iter().enumerate() {
if let Some(e) = self.ents.iter_mut().find(|e| e.id == *id && e.held) {
e.held = false;
e.x = cx as f32 + 1.5 + (k % 3) as f32 * 0.6 - 0.6;
e.y = cy as f32 + 3.5;
e.disp_x = e.x;
e.disp_y = e.y;
e.job = crate::game::Job::None;
e.path.clear();
}
}
// the heart is gone: its tiles become plain ground of that master
let owner = Owner::of_side(side);
for t in self.map.t.iter_mut() {
if t.kind == Kind::Core && t.owner == owner {
t.kind = Kind::Floor;
}
}
self.switch_to(me);
if side == self.local_side {
self.tell_local("msg.master_out_you", vec![], Some(crate::game::Sfx::Defeat));
} else {
self.tell_local("msg.master_out", vec![format!("@net.side{side}")], Some(crate::game::Sfx::CoreHit));
}
}
// goals of a mission played in a match: what one master holds (mana springs) counts for any of
// the masters still in the game, everything else is shared
pub fn match_goal_done(&self, goal: crate::game::Goal) -> bool {
match goal {
crate::game::Goal::Springs(n) => self.master_sides().into_iter().filter(|s| !self.is_out(*s)).any(|s| {
let springs = if s == self.me_side { self.springs } else { self.master_state(s).map_or(0, |m| m.springs) };
springs >= n as usize
}),
g => self.goal_done(g),
}
}
// The end of a match, decided from shared state only so that all clients stop in the same step:
// a mission (co-op) is lost when every master's heart fell; a versus match is over when only one
// team is left - the masters of that team win, the others lose.
pub(crate) fn check_match_end(&mut self) {
if self.state != 0 {
return;
}
let alive: Vec<u8> = self.master_sides().into_iter().filter(|s| !self.is_out(*s)).collect();
let lost = |g: &mut Game, key: &'static str| {
g.state = 2;
g.msg(key, vec![]);
g.sfx_ui(crate::game::Sfx::Defeat);
};
if let Some(m) = &self.mission {
let (limit, goals) = (m.time_limit, m.goals.clone());
if alive.is_empty() {
return lost(self, "msg.defeat");
}
if limit > 0.0 && self.time > limit {
return lost(self, "msg.time_up");
}
if !goals.is_empty() && goals.iter().all(|&g| self.match_goal_done(g)) {
self.state = 1;
self.msg("msg.mission_won", vec![]);
self.sfx_ui(crate::game::Sfx::Victory);
}
return;
}
// the teams that started (masters and AI seats) and the ones still standing
let mut sides: Vec<u8> = self.master_sides();
sides.extend(self.rivals.iter().map(|r| r.side));
let mut teams: Vec<u8> = sides.iter().map(|&s| self.team_of(s)).collect();
teams.sort_unstable();
teams.dedup();
let mut standing: Vec<u8> = alive.iter().copied().chain(self.rivals.iter().filter(|r| r.alive).map(|r| r.side)).map(|s| self.team_of(s)).collect();
standing.sort_unstable();
standing.dedup();
if alive.is_empty() {
return lost(self, "msg.defeat");
}
let over = if teams.len() >= 2 {
standing.len() <= 1
} else {
// everybody on one team: the humans' gate is the enemy
self.gate_on && self.gate_hp <= 0.0
};
if !over {
return;
}
if standing.contains(&self.team_of(self.local_side)) {
self.state = 1;
self.msg("msg.match_won", vec![]);
self.sfx_ui(crate::game::Sfx::Victory);
} else {
lost(self, "msg.match_lost");
}
}
// ------------------------------------------------------------ gold for allies
// the masters the local master can send gold to: human allies whose heart still beats
pub fn gold_allies(&self) -> Vec<u8> {
if self.netplay.is_none() || self.is_out(self.local_side) {
return Vec::new();
}
self.master_sides().into_iter().filter(|&s| s != self.local_side && !self.is_out(s) && self.allied(self.local_side, s)).collect()
}
// the master being processed sends gold to an ally (never more than he has, never more than
// the ally's treasury takes)
pub(crate) fn send_gold(&mut self, to: u8, amount: u32) {
let from = self.me_side;
if to == from || !self.is_master(to) || !self.allied(from, to) || self.is_out(to) || self.is_out(from) {
return;
}
let want = (amount as f32).min(self.gold.floor());
if want < 1.0 {
self.msg("msg.gold_none", vec![]);
self.sfx_ui(crate::game::Sfx::Error);
return;
}
self.switch_to(to);
let got = self.add_gold(want).floor();
if got >= 1.0 {
self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{from}")]);
self.sfx_ui(crate::game::Sfx::Payday);
}
self.switch_to(from);
if got < 1.0 {
self.msg("msg.gold_full", vec![format!("@net.side{to}")]);
self.sfx_ui(crate::game::Sfx::Error);
return;
}
self.gold -= got;
self.msg("msg.gold_sent", vec![(got as i32).to_string(), format!("@net.side{to}")]);
self.sfx_ui(crate::game::Sfx::Deposit);
}
// a match with masters on `sides` (of 0, 2, 3, 4), `local` is the master who plays at this computer // a match with masters on `sides` (of 0, 2, 3, 4), `local` is the master who plays at this computer
pub fn start_match(&mut self, sides: &[u8], local: u8) -> bool { pub fn start_match(&mut self, sides: &[u8], local: u8) -> bool {
if !self.start_masters(sides, local) { if !self.start_masters(sides, local) {
@@ -81,6 +356,9 @@ impl Game {
.filter(|side| *side != self.local_side) .filter(|side| *side != self.local_side)
.collect(); .collect();
for side in self.ai_sides.clone() { for side in self.ai_sides.clone() {
if self.is_out(side) {
continue; // a fallen master who left has nothing left for an AI to play
}
let core = if side == self.me_side { let core = if side == self.me_side {
continue; continue;
} else if let Some(m) = self.masters.iter().find(|m| m.side == side) { } else if let Some(m) = self.masters.iter().find(|m| m.side == side) {
@@ -169,7 +447,8 @@ impl Game {
pub fn checksum(&self) -> u64 { pub fn checksum(&self) -> u64 {
let mut h = FNV_OFFSET; let mut h = FNV_OFFSET;
mix(&mut h, self.sim_steps); mix(&mut h, self.sim_steps);
mix(&mut h, self.state as u64); // whether the world is still running: the end itself is told per team (won or lost)
mix(&mut h, (self.state != 0) as u64);
mix(&mut h, self.next_id as u64); mix(&mut h, self.next_id as u64);
mix(&mut h, self.rng.0 as u64); mix(&mut h, self.rng.0 as u64);
mix(&mut h, self.wave as u64); mix(&mut h, self.wave as u64);
@@ -269,6 +548,9 @@ impl Game {
for side in self.ai_sides.iter() { for side in self.ai_sides.iter() {
mix(&mut h, *side as u64); mix(&mut h, *side as u64);
} }
for side in self.out_sides.iter() {
mix(&mut h, 100 + *side as u64);
}
h h
} }
} }
@@ -371,6 +653,169 @@ mod tests {
assert_eq!(g.netplay.as_ref().unwrap().turn, 1); assert_eq!(g.netplay.as_ref().unwrap().turn, 1);
} }
// every client of one match: the same world, seen from another seat
fn clients(seed: u32, locals: &[u8], teams: &[(u8, u8)]) -> Vec<Game> {
locals
.iter()
.map(|&local| {
let mut g = client(seed, local);
g.set_teams(teams);
g
})
.collect()
}
fn turn_all(games: &mut [Game], cmds: &[Command]) {
for g in games.iter_mut() {
g.receive_turn(cmds);
}
}
fn same_world(games: &[Game], why: &str) {
let sum = games[0].checksum();
for g in games.iter().skip(1) {
assert_eq!(g.checksum(), sum, "{why}");
}
}
#[test]
fn a_fallen_master_watches_while_the_match_goes_on() {
let mut games = clients(41, &[0, 2, 3], &[]);
for g in games.iter_mut() {
g.damage_master_core(2, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
same_world(&games, "the heart fell in the same step everywhere");
for g in games.iter() {
assert!(g.is_out(2) && !g.is_out(0) && !g.is_out(3));
assert_eq!(g.state, 0, "the match goes on for the others");
}
let green = &games[1];
assert!(green.spectating() && !games[0].spectating());
assert!(green.msgs.iter().any(|m| m.key == "msg.master_out_you"));
assert!(games[0].msgs.iter().any(|m| m.key == "msg.master_out"));
assert!(!green.map.t.iter().any(|t| t.kind == Kind::Core && t.owner == Owner::Rival1), "the heart is broken");
let workers = green.ents.iter().filter(|e| e.alive && e.side == 2).count();
assert!(workers > 0, "his creatures go on without a heart");
// the spectator sees everything and cannot order anything
let mut games = games;
for _ in 0..30 {
turn_all(&mut games, &[]);
}
assert!(games[1].map.visible.iter().all(|v| *v), "no fog for the spectator");
assert!(!games[0].map.visible.iter().all(|v| *v), "the others still have fog");
let (gx, gy) = games[1].core_of_side(2);
games[1].queue(Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)));
assert!(games[1].take_orders().is_empty(), "a spectator gives no orders");
// an order that still comes in for the fallen side changes nothing
let stray = vec![Command { side: 2, seq: 0, act: Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)) }];
turn_all(&mut games, &stray);
assert!(!games[0].map.t[crate::map::idx(gx + 7, gy)].marked);
for _ in 0..50 {
turn_all(&mut games, &[]);
}
same_world(&games, "the spectator stays in step");
assert!(games.iter().all(|g| g.state == 0));
// the last enemy falls: red wins, blue (and the spectator) lose, in the same step
for g in games.iter_mut() {
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
same_world(&games, "the end came in the same step everywhere");
assert_eq!(games[0].state, 1, "red is the last master standing");
assert_eq!(games[1].state, 2);
assert_eq!(games[2].state, 2);
assert!(games[0].msgs.iter().any(|m| m.key == "msg.match_won"));
}
#[test]
fn team_mates_do_not_fight_and_win_together() {
// red and green are team A, blue is team B
let teams = [(0u8, 0u8), (2, 0), (3, 1)];
let mut games = clients(43, &[0, 2, 3], &teams);
let g = &games[0];
assert!(g.allied(0, 2) && g.allied(2, 0) && !g.allied(0, 3) && !g.allied(2, 3));
assert_eq!(games[1].gold_allies(), vec![0u8], "green can send gold to red");
assert!(games[2].gold_allies().is_empty(), "blue has no team mate");
// a creature of red next to one of green: no target; one of blue is
for g in games.iter_mut() {
let (cx, cy) = g.core_of_side(2);
let (x, y) = (cx as f32 + 4.5, cy as f32 + 4.5);
let red = g.spawn(crate::game::EK::Kobold, false, x, y, 3);
let green = g.spawn(crate::game::EK::Kobold, false, x + 1.0, y, 3);
for (id, side) in [(red, 0u8), (green, 2u8)] {
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.side = side;
}
}
let red_e = g.ents.iter().find(|e| e.id == red).unwrap().clone();
assert_eq!(g.nearest_enemy(&red_e, 3.0), None, "team mates are no targets");
let blue = g.spawn(crate::game::EK::Kobold, false, x, y + 1.0, 3);
if let Some(e) = g.ents.iter_mut().find(|e| e.id == blue) {
e.side = 3;
}
assert_eq!(g.nearest_enemy(&red_e, 3.0), Some(blue), "the other team is");
}
for _ in 0..40 {
turn_all(&mut games, &[]);
}
same_world(&games, "teams play the same on every client");
// the fog shows the team mate's ground
let (gx, gy) = games[0].core_of_side(2);
assert!(games[0].map.visible[crate::map::idx(gx + 1, gy + 1)], "red sees green's heart");
assert!(!games[2].map.visible[crate::map::idx(gx + 1, gy + 1)], "blue does not");
// one of team A falls: the match goes on; team B falls: team A wins together
for g in games.iter_mut() {
g.damage_master_core(0, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
assert!(games.iter().all(|g| g.state == 0), "green still stands for team A");
for g in games.iter_mut() {
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
same_world(&games, "the end came in the same step everywhere");
assert_eq!(games[0].state, 1, "red was out, but his team won");
assert_eq!(games[1].state, 1);
assert_eq!(games[2].state, 2);
}
#[test]
fn gold_goes_to_an_ally_on_every_client() {
let mut games = clients(45, &[0, 2, 3], &[(0, 0), (2, 0), (3, 1)]);
let red0 = games[0].gold_of(0);
let green0 = games[0].gold_of(2);
games[0].queue(Act::SendGold { to: 2, amount: 500 });
let orders = games[0].take_orders();
assert_eq!(orders.len(), 1);
turn_all(&mut games, &orders);
for g in games.iter() {
assert_eq!(g.gold_of(0), red0 - 500.0, "red paid");
assert_eq!(g.gold_of(2), green0 + 500.0, "green got it");
}
assert!(games[1].msgs.iter().any(|m| m.key == "msg.gold_received"), "the receiver hears of it");
assert!(games[1].events.iter().any(|e| e.sfx == crate::game::Sfx::Payday));
assert!(games[0].msgs.iter().any(|m| m.key == "msg.gold_sent"));
same_world(&games, "both clients have the same gold");
// not more than he has (and not more than the ally's treasury takes); never to an enemy
let all = vec![
Command { side: 2, seq: 0, act: Act::SendGold { to: 0, amount: crate::netplay::GOLD_ALL } },
Command { side: 0, seq: 1, act: Act::SendGold { to: 3, amount: 100 } },
Command { side: 3, seq: 0, act: Act::SendGold { to: 0, amount: 100 } },
];
let blue = games[0].gold_of(3);
let (red1, green1) = (games[0].gold_of(0), games[0].gold_of(2));
turn_all(&mut games, &all);
for g in games.iter() {
let sent = green1 - g.gold_of(2);
assert!(sent > 0.0 && sent <= green1, "green sends what he has: {sent}");
assert_eq!(g.gold_of(0), red1 + sent, "red gets all of it");
assert!(g.gold_of(0) <= g.gold_cap() + 0.5 || g.local_side != 0);
assert!((g.gold_of(3) - blue).abs() < 1.0, "blue got nothing from red and gave nothing");
}
same_world(&games, "still the same gold everywhere");
}
#[test] #[test]
fn a_world_that_develops_differently_is_caught_by_the_checksum() { fn a_world_that_develops_differently_is_caught_by_the_checksum() {
let (mut a, mut b) = (client(23, 0), client(23, 0)); let (mut a, mut b) = (client(23, 0), client(23, 0));
+12 -7
View File
@@ -202,9 +202,14 @@ impl Game {
targets.push((1, 3)); targets.push((1, 3));
} }
} else { } else {
targets.push((0, 3)); // the masters (the player, or the human masters of a match) that are not allies
for s in self.master_sides() {
if !self.is_out(s) && !self.allied(side, s) {
targets.push((s, 3));
}
}
for o in &self.rivals { for o in &self.rivals {
if o.alive && o.side != side && !self.is_coop_ally_side(o.side) { if o.alive && o.side != side && !self.allied(side, o.side) {
targets.push((o.side, 1)); targets.push((o.side, 1));
} }
} }
@@ -319,8 +324,8 @@ impl Game {
fn target_alive(&self, target: u8) -> bool { fn target_alive(&self, target: u8) -> bool {
match target { match target {
0 => self.core_hp > 0.0,
1 => self.has_gate() && self.gate_hp > 0.0, 1 => self.has_gate() && self.gate_hp > 0.0,
s if self.is_master(s) => !self.is_out(s) && self.core_hp_of(s) > 0.0,
s => self.rival_alive(s), s => self.rival_alive(s),
} }
} }
@@ -330,9 +335,9 @@ impl Game {
let dmg = e.atk(); let dmg = e.atk();
e.cd = st(e.kind).interval; e.cd = st(e.kind).interval;
match target { match target {
0 => { s if self.is_master(s) => {
self.core_hp -= dmg; self.damage_master_core(s, dmg);
let (cx, cy) = self.map.core; let (cx, cy) = self.core_of_side(s);
self.fx(2, (e.x + cx as f32 + 1.5) * 0.5, (e.y + cy as f32 + 1.5) * 0.5, 0.0, 0.0, 0.3, 0xff4040ff); self.fx(2, (e.x + cx as f32 + 1.5) * 0.5, (e.y + cy as f32 + 1.5) * 0.5, 0.0, 0.0, 0.3, 0xff4040ff);
self.sfx(Sfx::CoreHit, e.x, e.y); self.sfx(Sfx::CoreHit, e.x, e.y);
self.hint(6, "hint.core_attacked"); self.hint(6, "hint.core_attacked");
@@ -366,7 +371,7 @@ impl Game {
e.repath = 1.5; e.repath = 1.5;
// attackers dig their way like heroes; the player's creatures only walk // attackers dig their way like heroes; the player's creatures only walk
let goal = |i: usize| self.target_core_adjacent(i, target); let goal = |i: usize| self.target_core_adjacent(i, target);
let found = if e.side() == 0 { self.map.search(cur, self.map.creature_cost(flies(e.kind)), goal, true) } else { self.map.search(cur, self.map.hero_cost_as(flies(e.kind)), goal, true) }; let found = if !e.hero && self.is_master(e.side()) { self.map.search(cur, self.map.creature_cost(flies(e.kind)), goal, true) } else { self.map.search(cur, self.map.hero_cost_as(flies(e.kind)), goal, true) };
match found { match found {
Some(p) => e.path = p, Some(p) => e.path = p,
None => { None => {
+68 -5
View File
@@ -346,6 +346,8 @@ enum TBtn {
Overview, Overview,
OvRow(u32), OvRow(u32),
OvScroll(i32), OvScroll(i32),
GoldTo, // the next ally to receive gold
Gold(u32), // send that much gold to him
} }
pub enum UiAction { pub enum UiAction {
@@ -362,6 +364,7 @@ pub enum UiAction {
SelectLanguage(String), SelectLanguage(String),
ChatSetTeam(bool), ChatSetTeam(bool),
ChatSend, ChatSend,
SendGold(u8, u32), // gold for an ally of a match (side, amount)
Consumed, Consumed,
} }
@@ -369,6 +372,8 @@ pub struct Ui {
pub tool: Tool, pub tool: Tool,
pub show_help: bool, pub show_help: bool,
pub show_overview: bool, // list of all creatures pub show_overview: bool, // list of all creatures
pub coop_end: Option<(bool, bool, bool)>, // end of an editor co-op mission: (host, a next mission, asked for it)
gold_to: usize, // the ally chosen in the creature overview
ov_scroll: usize, ov_scroll: usize,
ov_rect: Option<Rect>, // where the creature overview is (mouse wheel, swipe) ov_rect: Option<Rect>, // where the creature overview is (mouse wheel, swipe)
pub show_langsel: bool, pub show_langsel: bool,
@@ -431,6 +436,8 @@ impl Ui {
tool: Tool::Dig, tool: Tool::Dig,
show_help: true, show_help: true,
show_overview: false, show_overview: false,
coop_end: None,
gold_to: 0,
ov_scroll: 0, ov_scroll: 0,
ov_rect: None, ov_rect: None,
show_langsel: false, show_langsel: false,
@@ -527,6 +534,17 @@ impl Ui {
self.ov_scroll = (self.ov_scroll as i32 + d).max(0) as usize; self.ov_scroll = (self.ov_scroll as i32 + d).max(0) as usize;
UiAction::Consumed UiAction::Consumed
} }
TBtn::GoldTo => {
self.gold_to += 1;
UiAction::Consumed
}
TBtn::Gold(amount) => {
let allies = g.gold_allies();
match allies.get(self.gold_to % allies.len().max(1)) {
Some(&to) => UiAction::SendGold(to, amount),
None => UiAction::Consumed,
}
}
TBtn::OvRow(id) => match g.ents.iter().find(|e| e.id == id) { TBtn::OvRow(id) => match g.ents.iter().find(|e| e.id == id) {
Some(e) => UiAction::MinimapJump(e.disp_x, e.disp_y), Some(e) => UiAction::MinimapJump(e.disp_x, e.disp_y),
None => UiAction::Consumed, None => UiAction::Consumed,
@@ -1036,6 +1054,32 @@ impl Ui {
let mood = if mine.is_empty() { 0.0 } else { mine.iter().map(|e| e.happy.clamp(0.0, 1.0)).sum::<f32>() / mine.len() as f32 }; let mood = if mine.is_empty() { 0.0 } else { mine.iter().map(|e| e.happy.clamp(0.0, 1.0)).sum::<f32>() / mine.len() as f32 };
let head = trf("ov.summary", &[mine.len().to_string(), (wage as i32).to_string(), ((mood * 100.0) as i32).to_string()]); let head = trf("ov.summary", &[mine.len().to_string(), (wage as i32).to_string(), ((mood * 100.0) as i32).to_string()]);
gfx.text(&head, x + CW() * 2.0, y + CH(), S(), GOLD); gfx.text(&head, x + CW() * 2.0, y + CH(), S(), GOLD);
// a match with allies: a row to send them gold (the name switches between the allies)
let allies = g.gold_allies();
let gold_row = !allies.is_empty();
if gold_row {
let to = allies[self.gold_to % allies.len()];
let gy = y + CH() * 2.2;
let name = trf("gold.send_to", &[tr(&format!("net.side{to}"))]);
let mut bx = x + CW() * 2.0;
let r = Rect::new(bx, gy, gfx.measure(&name, SMALL()) + CW() * 2.0, CH() * 1.6);
let hov = r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, side_color(Some(to)));
gfx.text(&name, r.x + CW(), r.y + (r.h - GH * SMALL()) * 0.5, SMALL(), side_color(Some(to)));
self.touch_btns.push((r, TBtn::GoldTo));
bx += r.w + CW();
for amount in crate::netplay::GOLD_AMOUNTS {
let label = if amount == crate::netplay::GOLD_ALL { tr("gold.all") } else { amount.to_string() };
let r = Rect::new(bx, gy, gfx.measure(&label, SMALL()) + CW() * 2.0, CH() * 1.6);
let hov = r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, GOLD);
gfx.text(&label, r.x + CW(), r.y + (r.h - GH * SMALL()) * 0.5, SMALL(), GOLD);
self.touch_btns.push((r, TBtn::Gold(amount)));
bx += r.w + CW() * 0.5;
}
}
// lines: kind headers and creatures // lines: kind headers and creatures
let mut kinds: Vec<EK> = Vec::new(); let mut kinds: Vec<EK> = Vec::new();
for e in &mine { for e in &mine {
@@ -1059,8 +1103,8 @@ impl Ui {
lines.push((line, col, Some(e.id), e.hp / e.max_hp)); lines.push((line, col, Some(e.id), e.hp / e.max_hp));
} }
} }
let first = y + CH() * 3.0; let first = y + CH() * if gold_row { 4.2 } else { 3.0 };
let visible = (rows - 5).max(1) as usize; let visible = (rows - if gold_row { 7 } else { 5 }).max(1) as usize;
self.ov_scroll = self.ov_scroll.min(lines.len().saturating_sub(visible)); self.ov_scroll = self.ov_scroll.min(lines.len().saturating_sub(visible));
let lw = (cols - 4) as f32 * CW(); let lw = (cols - 4) as f32 * CW();
for (n, (text, col, id, hp)) in lines.iter().enumerate().skip(self.ov_scroll).take(visible) { for (n, (text, col, id, hp)) in lines.iter().enumerate().skip(self.ov_scroll).take(visible) {
@@ -1094,6 +1138,15 @@ impl Ui {
pub fn draw_dialogs(&mut self, g: &Game, gfx: &Gfx) { pub fn draw_dialogs(&mut self, g: &Game, gfx: &Gfx) {
let sw = sw(); let sw = sw();
let sh = sh(); let sh = sh();
if g.spectating() && g.state == 0 && !self.show_langsel {
// the heart fell: the player watches the rest of the match
let t = tr("net.spectating");
let w = ((gfx.measure(&t, S()) / CW()).ceil() as i32 + 4).min((sw / CW()) as i32);
let x = grid_center(sw, w as f32 * CW());
let y = self.hud_h + CH() * 0.5;
gfx.window(x, y, w, 3, S(), false, RED, BG);
gfx.text(&t, grid_center(sw, gfx.measure(&t, S())), y + CH(), S(), RED);
}
if self.show_langsel { if self.show_langsel {
self.draw_langsel(gfx); self.draw_langsel(gfx);
} else if g.state != 0 { } else if g.state != 0 {
@@ -1258,10 +1311,20 @@ impl Ui {
let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]); let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]);
gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * (4.5 + lines.len() as f32), S(), DIM); gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * (4.5 + lines.len() as f32), S(), DIM);
// buttons: free game: new game; mission: next / again and back to the campaign // buttons: free game: new game; mission: next / again and back to the campaign
let labels: Vec<(String, u8)> = match mission { let labels: Vec<(String, u8)> = match (mission, self.coop_end) {
None => vec![(tr("end.new"), 0)], // an editor campaign in co-op: the host goes on for everybody, the others wait or leave
Some(_) => vec![(tr(if !win { "end.again" } else if last { "end.to_campaign" } else { "end.next" }), 1), (tr("end.campaign"), 2)], (_, Some((true, true, false))) => vec![(tr(if win { "end.next" } else { "end.again" }), 1), (tr("net.leave"), 2)],
(_, Some((true, true, true))) | (_, Some((false, true, _))) => vec![(tr("net.leave"), 2)],
(_, Some((_, false, _))) => vec![(tr("net.coop_run_over"), 2)],
(None, None) => vec![(tr("end.new"), 0)],
(Some(_), None) => vec![(tr(if !win { "end.again" } else if last { "end.to_campaign" } else { "end.next" }), 1), (tr("end.campaign"), 2)],
}; };
if let Some((host, true, sent)) = self.coop_end {
if !host || sent {
let t = tr("net.coop_wait_host");
gfx.text(&t, grid_center(sw, gfx.measure(&t, SMALL())), y + CH() * (5.3 + lines.len() as f32), SMALL(), GOLD);
}
}
let by = y + CH() * (6.0 + lines.len() as f32); let by = y + CH() * (6.0 + lines.len() as f32);
let widths: Vec<f32> = labels.iter().map(|(l, _)| gfx.measure(l, S()) + CW() * 4.0).collect(); let widths: Vec<f32> = labels.iter().map(|(l, _)| gfx.measure(l, S()) + CW() * 4.0).collect();
let total: f32 = widths.iter().sum::<f32>() + CW() * 2.0 * (labels.len() as f32 - 1.0); let total: f32 = widths.iter().sum::<f32>() + CW() * 2.0 * (labels.len() as f32 - 1.0);