SirVer has proposed merging lp:~widelands-dev/widelands/market1 into lp:widelands.
Commit message: First working land-based trading implementation. A trade works like this: A player proposes to another players market to initiate a trade. A trade consists of the three pieces of information: what wares will I sent, what wares do I want from you and how often do we exchange. For example I'll send 3 logs whenever you send 1 plank and 5 iron and we do ship these batches 10 times. Once a trade is initiated, both players will require the correct number of oxen for a batch, i.e. p1 requires 3 (for 3 logs) and p2 requires 6 (1 plank + 5 iron). Once all workers and wares for one batch have arrived in the markets, both markets will launch one caravan and the items gets exchanged. This is a minimal implementation and not ready for use in-game. Highlights of stuff still missing: - UI (clicking a market will crash the game). - denying/accepting a proposed trade. Every proposed trade is auto-accepted atm. - releasing trade carriers and left-over wares once a trade is cancelled or fulfilled. - load/save/serialize for networking for all commands and new map objects. Implemented: - Adds Lua functions to propose a trade from Lua. - Adds a PlayerCommand to propose a trade from Lua, but this is still unused. - Adds logic to trade between two Markets. - Adds a test that does a simple trade. The test is run with the regular regression test runs, but can be run standalone using: ./build/debug/src/widelands --datadir=data --datadir_for_testing=test --scenario=maps/market_trading.wmf --script=maps/market_trading.wmf/scripting/test_simple_trade.lua Requested reviews: Widelands Developers (widelands-dev) For more details, see: https://code.launchpad.net/~widelands-dev/widelands/market1/+merge/331233 -- Your team Widelands Developers is requested to review the proposed merge of lp:~widelands-dev/widelands/market1 into lp:widelands.
=== modified file 'data/tribes/buildings/markets/barbarians/market/init.lua' --- data/tribes/buildings/markets/barbarians/market/init.lua 2017-09-15 19:53:28 +0000 +++ data/tribes/buildings/markets/barbarians/market/init.lua 2017-09-22 21:01:30 +0000 @@ -50,7 +50,5 @@ prohibited_till = 1000 }, - working_positions = { - barbarians_ox = 10, - }, + carrier = "barbarians_ox", } === modified file 'src/economy/request.h' --- src/economy/request.h 2017-01-25 18:55:59 +0000 +++ src/economy/request.h 2017-09-22 21:01:30 +0000 @@ -123,6 +123,7 @@ // callbacks for WareInstance/Worker code void transfer_finish(Game&, Transfer&); void transfer_fail(Game&, Transfer&); + void cancel_transfer(uint32_t idx); void set_requirements(const Requirements& r) { requirements_ = r; @@ -131,13 +132,9 @@ return requirements_; } + private: int32_t get_base_required_time(EditorGameBase&, uint32_t nr) const; - -public: - void cancel_transfer(uint32_t idx); - -private: void remove_transfer(uint32_t idx); uint32_t find_transfer(Transfer&); === modified file 'src/economy/ware_instance.cc' --- src/economy/ware_instance.cc 2017-08-16 10:14:29 +0000 +++ src/economy/ware_instance.cc 2017-09-22 21:01:30 +0000 @@ -309,8 +309,9 @@ // Update whether we have a Supply or not if (!transfer_ || !transfer_->get_request()) { - if (!supply_) + if (!supply_) { supply_ = new IdleWareSupply(*this); + } } else { delete supply_; supply_ = nullptr; === modified file 'src/game_io/game_class_packet.cc' --- src/game_io/game_class_packet.cc 2017-01-25 18:55:59 +0000 +++ src/game_io/game_class_packet.cc 2017-09-22 21:01:30 +0000 @@ -61,6 +61,8 @@ // Write gametime fw.unsigned_32(game.gametime_); + // TODO(sirver,trading): save/load trade_agreements and related data. + // We do not care for players, since they were set // on game initialization to match Map::scenario_player_[names|tribes] // or vice versa, so this is handled by map loader === modified file 'src/logic/CMakeLists.txt' --- src/logic/CMakeLists.txt 2017-09-15 12:33:58 +0000 +++ src/logic/CMakeLists.txt 2017-09-22 21:01:30 +0000 @@ -95,14 +95,14 @@ findimmovable.h findnode.cc findnode.h + game.cc + game.h game_data_error.cc game_data_error.h - game.cc - game.h + map.cc + map.h map_revision.cc map_revision.h - map.cc - map.h mapastar.cc mapastar.h mapdifferenceregion.cc @@ -114,18 +114,18 @@ mapregion.h maptriangleregion.cc maptriangleregion.h + message.h message_id.h message_queue.h - message.h nodecaps.h objective.h path.cc path.h pathfield.cc pathfield.h - player_area.h player.cc player.h + player_area.h playercommand.cc playercommand.h playersmanager.cc @@ -138,6 +138,7 @@ roadtype.h save_handler.cc save_handler.h + trade_agreement.h widelands_geometry_io.cc widelands_geometry_io.h map_objects/bob.cc === modified file 'src/logic/game.cc' --- src/logic/game.cc 2017-08-30 12:01:47 +0000 +++ src/logic/game.cc 2017-09-22 21:01:30 +0000 @@ -49,6 +49,7 @@ #include "logic/cmd_luascript.h" #include "logic/game_settings.h" #include "logic/map_objects/tribes/carrier.h" +#include "logic/map_objects/tribes/market.h" #include "logic/map_objects/tribes/militarysite.h" #include "logic/map_objects/tribes/ship.h" #include "logic/map_objects/tribes/soldier.h" @@ -753,6 +754,87 @@ get_gametime(), ship.get_owner()->player_number(), ship.serial())); } +void Game::send_player_propose_trade(const Trade& trade) { + auto* object = objects().get_object(trade.initiator); + assert(object != nullptr); + send_player_command( + *new CmdProposeTrade(get_gametime(), object->get_owner()->player_number(), trade)); +} + +int Game::propose_trade(const Trade& trade) { + // TODO(sirver,trading): Check if a trade is possible (i.e. if there is a + // path between the two markets); + const int id = next_trade_agreement_id_; + ++next_trade_agreement_id_; + + auto* initiator = dynamic_cast<Market*>(objects().get_object(trade.initiator)); + auto* receiver = dynamic_cast<Market*>(objects().get_object(trade.receiver)); + // This is only ever called through a PlayerCommand and that already made + // sure that the objects still exist. Since no time has passed, they should + // not have vanished under us. + assert(initiator != nullptr); + assert(receiver != nullptr); + + receiver->removed.connect( + [this, id](const uint32_t /* serial */) { cancel_trade(id); }); + initiator->removed.connect( + [this, id](const uint32_t /* serial */) { cancel_trade(id); }); + + receiver->send_message(*this, Message::Type::kTradeOfferReceived, receiver->descr().descname(), + receiver->descr().icon_filename(), receiver->descr().descname(), + _("This Market received a new trade offer."), true); + trade_agreements_[id] = TradeAgreement{TradeAgreement::State::kProposed, trade}; + + // TODO(sirver): this should be done through another player_command, but I + // want to get to the trade logic implementation now. + accept_trade(id); + return id; +} + +void Game::accept_trade(const int trade_id) { + auto it = trade_agreements_.find(trade_id); + if (it == trade_agreements_.end()) { + log("Game::accept_trade: Trade %d has vanished. Ignoring.\n", trade_id); + return; + } + const Trade& trade = it->second.trade; + auto* initiator = dynamic_cast<Market*>(objects().get_object(trade.initiator)); + auto* receiver = dynamic_cast<Market*>(objects().get_object(trade.receiver)); + if (initiator == nullptr || receiver == nullptr) { + cancel_trade(trade_id); + return; + } + + initiator->new_trade(trade_id, trade.send_items, trade.num_batches, trade.receiver); + receiver->new_trade(trade_id, trade.received_items, trade.num_batches, trade.initiator); + + // TODO(sirver,trading): Message the users that the trade has been accepted. +} + +void Game::cancel_trade(int trade_id) { + // The trade id might be long gone - since we never disconnect from the + // 'removed' signal of the two buildings, we might be invoked long after the + // trade was deleted for other reasons. + const auto it = trade_agreements_.find(trade_id); + if (it == trade_agreements_.end()) { + return; + } + const auto& trade = it->second.trade; + + auto* initiator = dynamic_cast<Market*>(objects().get_object(trade.initiator)); + if (initiator != nullptr) { + initiator->cancel_trade(trade_id); + // TODO(sirver,trading): Send message to owner that the trade has been canceled. + } + + auto* receiver = dynamic_cast<Market*>(objects().get_object(trade.receiver)); + if (receiver != nullptr) { + receiver->cancel_trade(trade_id); + // TODO(sirver,trading): Send message to owner that the trade has been canceled. + } + trade_agreements_.erase(trade_id); +} + LuaGameInterface& Game::lua() { return static_cast<LuaGameInterface&>(EditorGameBase::lua()); } === modified file 'src/logic/game.h' --- src/logic/game.h 2017-08-18 00:17:39 +0000 +++ src/logic/game.h 2017-09-22 21:01:30 +0000 @@ -27,6 +27,7 @@ #include "logic/cmd_queue.h" #include "logic/editor_game_base.h" #include "logic/save_handler.h" +#include "logic/trade_agreement.h" #include "random/random.h" #include "scripting/logic.h" @@ -207,6 +208,7 @@ void send_player_ship_explore_island(Ship&, IslandExploreDirection); void send_player_sink_ship(Ship&); void send_player_cancel_expedition_ship(Ship&); + void send_player_propose_trade(const Trade& trade); InteractivePlayer* get_ipl(); @@ -244,6 +246,11 @@ void set_auto_speed(bool); + // TODO(sirver,trading): document these functions once the interface settles. + int propose_trade(const Trade& trade); + void accept_trade(int trade_id); + void cancel_trade(int trade_id); + private: void sync_reset(); @@ -308,6 +315,9 @@ std::unique_ptr<ReplayWriter> replaywriter_; GeneralStatsVector general_stats_; + int next_trade_agreement_id_ = 1; + // Maps from trade agreement id to the agreement. + std::map<int, TradeAgreement> trade_agreements_; /// For save games and statistics generation std::string win_condition_displayname_; === modified file 'src/logic/map_objects/bob.cc' --- src/logic/map_objects/bob.cc 2017-08-20 08:34:02 +0000 +++ src/logic/map_objects/bob.cc 2017-09-22 21:01:30 +0000 @@ -390,6 +390,10 @@ state.ivar1 = 0; } +bool Bob::is_idle() { + return get_state(taskIdle); +} + /** * Move along a predefined path. * \par ivar1 the step number. === modified file 'src/logic/map_objects/bob.h' --- src/logic/map_objects/bob.h 2017-06-24 08:47:46 +0000 +++ src/logic/map_objects/bob.h 2017-09-22 21:01:30 +0000 @@ -278,6 +278,7 @@ // TODO(feature-Hasi50): correct (?) Send a signal that may switch to some other \ref Task void send_signal(Game&, char const*); void start_task_idle(Game&, uint32_t anim, int32_t timeout); + bool is_idle(); /// This can fail (and return false). Therefore the caller must check the /// result and find something else for the bob to do. Otherwise there will === modified file 'src/logic/map_objects/tribes/market.cc' --- src/logic/map_objects/tribes/market.cc 2017-09-18 13:43:08 +0000 +++ src/logic/map_objects/tribes/market.cc 2017-09-22 21:01:30 +0000 @@ -19,8 +19,11 @@ #include "logic/map_objects/tribes/market.h" +#include <memory> + #include "base/i18n.h" #include "logic/map_objects/tribes/productionsite.h" +#include "logic/map_objects/tribes/tribes.h" namespace Widelands { @@ -30,7 +33,12 @@ : BuildingDescr(init_descname, MapObjectType::MARKET, table, egbase) { i18n::Textdomain td("tribes"); - parse_working_positions(egbase, table.get_table("working_positions").get(), &working_positions_); + DescriptionIndex const woi = egbase.tribes().worker_index(table.get_string("carrier")); + if (!egbase.tribes().worker_exists(woi)) { + throw wexception("invalid"); + } + carrier_ = woi; + // TODO(sirver,trading): Add actual logic here. } @@ -38,10 +46,223 @@ return *new Market(*this); } +int Market::TradeOrder::num_wares_per_batch() const { + int sum = 0; + for (const auto& item_pair : items) { + sum += item_pair.second; + } + return sum; +} + +bool Market::TradeOrder::fulfilled() const { + return num_shipped_batches == initial_num_batches; +} + +// TODO(sirver,trading): This needs to implement 'set_economy'. Maybe common code can be shared. Market::Market(const MarketDescr& the_descr) : Building(the_descr) { } Market::~Market() { } +void Market::new_trade(const int trade_id, + const BillOfMaterials& items, + const int num_batches, + const Serial other_side) { + trade_orders_[trade_id] = TradeOrder{items, num_batches, 0, other_side, 0, nullptr, {}, {}}; + auto& trade_order = trade_orders_[trade_id]; + + // Request one worker for each item in a batch. + trade_order.worker_request.reset( + new Request(*this, descr().carrier(), Market::worker_arrived_callback, wwWORKER)); + trade_order.worker_request->set_count(trade_order.num_wares_per_batch()); + + // Make sure we have a wares queue for each item in this. + for (const auto& entry : items) { + ensure_wares_queue_exists(entry.first); + } +} + +void Market::cancel_trade(const int trade_id) { + // TODO(sirver,trading): Launch workers, release no longer required wares and delete now unneeded 'WaresQueue's + trade_orders_.erase(trade_id); +} + +void Market::worker_arrived_callback( + Game& game, Request& rq, DescriptionIndex /* widx */, Worker* const w, PlayerImmovable& target) { + auto& market = dynamic_cast<Market&>(target); + + assert(w); + assert(w->get_location(game) == &market); + + for (auto& trade_order_pair : market.trade_orders_) { + auto& trade_order = trade_order_pair.second; + if (trade_order.worker_request.get() != &rq) { + continue; + } + + if (rq.get_count() == 0) { + // Erase this request. + trade_order.worker_request.reset(); + } + w->start_task_idle(game, 0, -1); + trade_order.workers.push_back(w); + Notifications::publish(NoteBuilding(market.serial(), NoteBuilding::Action::kWorkersChanged)); + market.try_launching_batch(&game); + return; + } + NEVER_HERE(); // We should have found and handled a match by now. +} + +void Market::ware_arrived_callback(Game& g, InputQueue*, DescriptionIndex, Worker*, void* data) { + Market& market = *static_cast<Market*>(data); + market.try_launching_batch(&g); +} + +void Market::try_launching_batch(Game* game) { + for (auto& pair : trade_orders_) { + if (!is_ready_to_launch_batch(pair.first)) { + continue; + } + + auto* other_market = + dynamic_cast<Market*>(game->objects().get_object(pair.second.other_side)); + if (other_market == nullptr) { + // TODO(sirver,trading): Can this even happen? Where is this function called from. + // The other market seems to have vanished. The game tracks this and + // should soon delete this trade request from us. We just ignore it. + continue; + } + if (!other_market->is_ready_to_launch_batch(pair.first)) { + continue; + } + launch_batch(pair.first, game); + other_market->launch_batch(pair.first, game); + break; + } +} + +bool Market::is_ready_to_launch_batch(const int trade_id) { + const auto it = trade_orders_.find(trade_id); + if (it == trade_orders_.end()) { + return false; + } + auto& trade_order = it->second; + assert(!trade_order.fulfilled()); + + // Do we have all necessary wares for a batch? + for (const auto& item_pair : trade_order.items) { + const auto wares_it = wares_queue_.find(item_pair.first); + if (wares_it == wares_queue_.end()) { + return false; + } + if (wares_it->second->get_filled() < item_pair.second) { + return false; + } + } + + // Do we have enough people to carry wares? + int num_available_carriers = 0; + for (auto* worker : trade_order.workers) { + num_available_carriers += worker->is_idle() ? 1 : 0; + } + return num_available_carriers == trade_order.num_wares_per_batch(); +} + +void Market::launch_batch(const int trade_id, Game* game) { + assert(is_ready_to_launch_batch(trade_id)); + auto& trade_order = trade_orders_.at(trade_id); + + // Do we have all necessary wares for a batch? + int worker_index = 0; + for (const auto& item_pair : trade_order.items) { + for (size_t i = 0; i < item_pair.second; ++i) { + Worker* carrier = trade_order.workers.at(worker_index); + ++worker_index; + assert(carrier->is_idle()); + + // Give the carrier a ware. + WareInstance* ware = + new WareInstance(item_pair.first, game->tribes().get_ware_descr(item_pair.first)); + ware->init(*game); + carrier->set_carried_ware(*game, ware); + + // We have to remove this item from our economy. Otherwise it would be + // considered idle (since it has no transport associated with it) and + // the engine would want to transfer it to the next warehouse. + ware->set_economy(nullptr); + wares_queue_.at(item_pair.first) + ->set_filled(wares_queue_.at(item_pair.first)->get_filled() - 1); + + // Send the carrier going. + carrier->reset_tasks(*game); + carrier->start_task_carry_trade_item( + *game, trade_id, ObjectPointer(game->objects().get_object(trade_order.other_side))); + } + } +} + +void Market::ensure_wares_queue_exists(int ware_index) { + if (wares_queue_.count(ware_index) > 0) { + return; + } + wares_queue_[ware_index] = + std::unique_ptr<WaresQueue>(new WaresQueue(*this, ware_index, kMaxPerItemTradeBatchSize)); + wares_queue_[ware_index]->set_callback(Market::ware_arrived_callback, this); +} + +InputQueue& Market::inputqueue(DescriptionIndex index, WareWorker ware_worker) { + assert(ware_worker == wwWARE); + auto it = wares_queue_.find(index); + if (it != wares_queue_.end()) { + return *it->second; + } + // The parent will throw an exception. + return Building::inputqueue(index, ware_worker); +} + +void Market::cleanup(EditorGameBase& egbase) { + for (auto& pair : wares_queue_) { + pair.second->cleanup(); + } + Building::cleanup(egbase); +} + +void Market::traded_ware_arrived(const int trade_id, const DescriptionIndex ware_index, Game* game) { + auto& trade_order = trade_orders_.at(trade_id); + + WareInstance* ware = new WareInstance(ware_index, game->tribes().get_ware_descr(ware_index)); + ware->init(*game); + + // TODO(sirver,trading): This is a hack. We should have a worker that + // carriers stuff out. At the moment this assumes this market is barbarians + // (which is always correct right now), creates a carrier for each received + // ware to drop it off. The carrier then leaves the building and goes home. + const WorkerDescr& w_desc = + *game->tribes().get_worker_descr(game->tribes().worker_index("barbarians_carrier")); + auto& worker = w_desc.create(*game, owner(), this, position_); + worker.start_task_dropoff(*game, *ware); + trade_order.received_traded_wares_in_this_batch += 1; + owner().ware_produced(ware_index); + + auto* other_market = dynamic_cast<Market*>(game->objects().get_object(trade_order.other_side)); + assert(other_market != nullptr); + other_market->owner().ware_consumed(ware_index, 1); + auto& other_trade_order = other_market->trade_orders_.at(trade_id); + if (trade_order.received_traded_wares_in_this_batch == other_trade_order.num_wares_per_batch() && + other_trade_order.received_traded_wares_in_this_batch == trade_order.num_wares_per_batch()) { + // This batch is completed. + ++trade_order.num_shipped_batches; + trade_order.received_traded_wares_in_this_batch = 0; + ++other_trade_order.num_shipped_batches; + other_trade_order.received_traded_wares_in_this_batch = 0; + if (trade_order.fulfilled()) { + assert(other_trade_order.fulfilled()); + // TODO(sirver,trading): This is not quite correct. This should for + // example send a differnet message than actually canceling a trade. + game->cancel_trade(trade_id); + } + } +} + } // namespace Widelands === modified file 'src/logic/map_objects/tribes/market.h' --- src/logic/map_objects/tribes/market.h 2017-09-15 12:33:58 +0000 +++ src/logic/map_objects/tribes/market.h 2017-09-22 21:01:30 +0000 @@ -20,6 +20,10 @@ #ifndef WL_LOGIC_MAP_OBJECTS_TRIBES_MARKET_H #define WL_LOGIC_MAP_OBJECTS_TRIBES_MARKET_H +#include <memory> + +#include "economy/request.h" +#include "economy/wares_queue.h" #include "logic/map_objects/tribes/building.h" namespace Widelands { @@ -32,8 +36,10 @@ Building& create_object() const override; + DescriptionIndex carrier() const { return carrier_; } + private: - BillOfMaterials working_positions_; + DescriptionIndex carrier_; }; class Market : public Building { @@ -42,7 +48,55 @@ explicit Market(const MarketDescr& descr); ~Market() override; + void new_trade(int trade_id, const BillOfMaterials& items, int num_batches, Serial other_side); + void cancel_trade(int trade_id); + + InputQueue& inputqueue(DescriptionIndex, WareWorker) override; + void cleanup(EditorGameBase&) override; + + void try_launching_batch(Game* game); + void traded_ware_arrived(int trade_id, DescriptionIndex ware_index, Game* game); + private: + struct WareRequest { + int index; + std::unique_ptr<Request> request; + }; + + struct TradeOrder { + BillOfMaterials items; + int initial_num_batches; + int num_shipped_batches; + Serial other_side; + + int received_traded_wares_in_this_batch; + + // The invariant here is that worker.size() + worker_request.get_count() + // == 'num_wares_per_batch()' + std::unique_ptr<Request> worker_request; + std::vector<Worker*> workers; + + std::vector<WareRequest> ware_requests; + + // The number of individual wares in 'items', i.e. the sum of all '.second's. + int num_wares_per_batch() const; + + // True if the 'num_shipped_batches' equals the 'initial_num_batches' + bool fulfilled() const; + }; + + static void + worker_arrived_callback(Game&, Request&, DescriptionIndex, Worker*, PlayerImmovable&); + static void + ware_arrived_callback(Game& g, InputQueue* q, DescriptionIndex ware, Worker* worker, void* data); + + void ensure_wares_queue_exists(int ware_index); + bool is_ready_to_launch_batch(int trade_id); + void launch_batch(int trade_id, Game* game); + + std::map<int, TradeOrder> trade_orders_; // Key is 'trade_id's. + std::map<int, std::unique_ptr<WaresQueue>> wares_queue_; // Key is 'ware_index'. + DISALLOW_COPY_AND_ASSIGN(Market); }; === modified file 'src/logic/map_objects/tribes/productionsite.cc' --- src/logic/map_objects/tribes/productionsite.cc 2017-09-15 12:33:58 +0000 +++ src/logic/map_objects/tribes/productionsite.cc 2017-09-22 21:01:30 +0000 @@ -49,8 +49,9 @@ constexpr size_t STATISTICS_VECTOR_LENGTH = 20; -} // namespace - +// Parses the descriptions of the working positions from 'items_table' and +// fills in 'working_positions'. Throws an error if the table contains invalid +// values. void parse_working_positions(const EditorGameBase& egbase, LuaTable* items_table, BillOfMaterials* working_positions) { @@ -71,6 +72,8 @@ } } +} // namespace + /* ============================================================================== === modified file 'src/logic/map_objects/tribes/productionsite.h' --- src/logic/map_objects/tribes/productionsite.h 2017-09-18 13:43:08 +0000 +++ src/logic/map_objects/tribes/productionsite.h 2017-09-22 21:01:30 +0000 @@ -375,13 +375,6 @@ } }; -// Parses the descriptions of the working positions from 'items_table' and -// fills in 'working_positions'. Throws an error if the table contains invalid -// values. -void parse_working_positions(const EditorGameBase& egbase, - LuaTable* items_table, - BillOfMaterials* working_positions); - } // namespace Widelands #endif // end of include guard: WL_LOGIC_MAP_OBJECTS_TRIBES_PRODUCTIONSITE_H === modified file 'src/logic/map_objects/tribes/tribes.cc' --- src/logic/map_objects/tribes/tribes.cc 2017-09-15 12:33:58 +0000 +++ src/logic/map_objects/tribes/tribes.cc 2017-09-22 21:01:30 +0000 @@ -24,6 +24,7 @@ #include "base/wexception.h" #include "graphic/graphic.h" #include "logic/game_data_error.h" +#include "logic/map_objects/tribes/market.h" namespace Widelands { === modified file 'src/logic/map_objects/tribes/tribes.h' --- src/logic/map_objects/tribes/tribes.h 2017-09-15 12:33:58 +0000 +++ src/logic/map_objects/tribes/tribes.h 2017-09-22 21:01:30 +0000 @@ -38,7 +38,6 @@ #include "logic/map_objects/tribes/tribe_descr.h" #include "logic/map_objects/tribes/ware_descr.h" #include "logic/map_objects/tribes/warehouse.h" -#include "logic/map_objects/tribes/market.h" #include "logic/map_objects/tribes/worker_descr.h" #include "scripting/lua_table.h" === modified file 'src/logic/map_objects/tribes/worker.cc' --- src/logic/map_objects/tribes/worker.cc 2017-08-20 17:45:42 +0000 +++ src/logic/map_objects/tribes/worker.cc 2017-09-22 21:01:30 +0000 @@ -47,6 +47,7 @@ #include "logic/map_objects/terrain_affinity.h" #include "logic/map_objects/tribes/carrier.h" #include "logic/map_objects/tribes/dismantlesite.h" +#include "logic/map_objects/tribes/market.h" #include "logic/map_objects/tribes/soldier.h" #include "logic/map_objects/tribes/tribe_descr.h" #include "logic/map_objects/tribes/warehouse.h" @@ -1590,7 +1591,89 @@ * is finished. */ void Worker::update_task_buildingwork(Game& game) { - if (top_state().task == &taskBuildingwork) + if (top_state().task == &taskCarryTradeItem) + send_signal(game, "update"); +} + +// The task when a worker is part of the caravane that is trading items. +const Bob::Task Worker::taskCarryTradeItem = { + "carry_trade_item", static_cast<Bob::Ptr>(&Worker::carry_trade_item_update), nullptr, nullptr, true}; + +void Worker::start_task_carry_trade_item(Game& game, + const int trade_id, + ObjectPointer other_market) { + push_task(game, taskCarryTradeItem); + auto& state = top_state(); + state.ivar1 = 0; + state.ivar2 = trade_id; + state.objvar1 = other_market; +} + +// This is a state machine: leave building, go to the other market, drop off +// wares, and return. +void Worker::carry_trade_item_update(Game& game, State& state) { + // Reset any signals that are not related to location + std::string signal = get_signal(); + signal_handled(); + if (!signal.empty()) { + // TODO(sirver,trading): Remove once signals are correctly handled. + log("carry_trade_item_update: signal received: %s\n", signal.c_str()); + } + if (signal == "evict") { + return pop_task(game); + } + + // First of all, make sure we're outside + if (state.ivar1 == 0) { + start_task_leavebuilding(game, false); + ++state.ivar1; + return; + } + + auto* other_market = dynamic_cast<Market*>(state.objvar1.get(game)); + if (state.ivar1 == 1) { + // Arrived on site. Move to the building and advance our state. + if (other_market->base_flag().get_position() == get_position()) { + ++state.ivar1; + return start_task_move( + game, WALK_NW, descr().get_right_walk_anims(does_carry_ware()), true); + } + + // Otherwise continue making progress towards the other market. + if (!start_task_movepath(game, other_market->base_flag().get_position(), 5, + descr().get_right_walk_anims(does_carry_ware()))) { + molog("carry_trade_item_update: Could not move to other flag.\n"); + // TODO(sirver,trading): something needs to happen here. + } + return; + } + + if (state.ivar1 == 2) { + WareInstance* const ware = fetch_carried_ware(game); + other_market->traded_ware_arrived(state.ivar2, ware->descr_index(), &game); + ware->remove(game); + ++state.ivar1; + start_task_move(game, WALK_SE, descr().get_right_walk_anims(does_carry_ware()), true); + return; + } + + if (state.ivar1 == 3) { + ++state.ivar1; + start_task_return(game, false); + return; + } + + if (state.ivar1 == 4) { + pop_task(game); + start_task_idle(game, 0, -1); + dynamic_cast<Market*>(get_location(game))->try_launching_batch(&game); + return; + } + NEVER_HERE(); +} + +void Worker::update_task_carry_trade_item(Game& game) { + if (top_state().task == &taskCarryTradeItem) send_signal(game, "update"); } === modified file 'src/logic/map_objects/tribes/worker.h' --- src/logic/map_objects/tribes/worker.h 2017-06-24 08:47:46 +0000 +++ src/logic/map_objects/tribes/worker.h 2017-09-22 21:01:30 +0000 @@ -169,6 +169,9 @@ void start_task_leavebuilding(Game&, bool changelocation); void start_task_fugitive(Game&); + void start_task_carry_trade_item(Game& game, int trade_id, ObjectPointer other_market); + void update_task_carry_trade_item(Game&); + void start_task_geologist(Game&, uint8_t attempts, uint8_t radius, const std::string& subcommand); @@ -208,6 +211,7 @@ static const Task taskFugitive; static const Task taskGeologist; static const Task taskScout; + static const Task taskCarryTradeItem; private: // task details @@ -232,6 +236,7 @@ void fugitive_update(Game&, State&); void geologist_update(Game&, State&); void scout_update(Game&, State&); + void carry_trade_item_update(Game&, State&); // Program commands bool run_mine(Game&, State&, const Action&); === modified file 'src/logic/message.h' --- src/logic/message.h 2017-02-19 16:56:59 +0000 +++ src/logic/message.h 2017-09-22 21:01:30 +0000 @@ -48,7 +48,8 @@ kWarfare, // everything starting from here is warfare kWarfareSiteDefeated, kWarfareSiteLost, - kWarfareUnderAttack + kWarfareUnderAttack, + kTradeOfferReceived, }; /** === modified file 'src/logic/playercommand.cc' --- src/logic/playercommand.cc 2017-06-25 21:56:53 +0000 +++ src/logic/playercommand.cc 2017-09-22 21:01:30 +0000 @@ -29,6 +29,7 @@ #include "io/streamwrite.h" #include "logic/game.h" #include "logic/map_objects/map_object.h" +#include "logic/map_objects/tribes/market.h" #include "logic/map_objects/tribes/soldier.h" #include "logic/map_objects/tribes/tribe_descr.h" #include "logic/player.h" @@ -52,6 +53,25 @@ return mol.get<T>(object_index).serial(); } +void serialize_bill_of_materials(const BillOfMaterials& bill, StreamWrite* ser) { + ser->unsigned_32(bill.size()); + for (const WareAmount& amount : bill) { + ser->unsigned_8(amount.first); + ser->unsigned_32(amount.second); + } +} + +BillOfMaterials deserialize_bill_of_materials(StreamRead* des) { + BillOfMaterials bill; + const int count = des->unsigned_32(); + for (int i = 0; i < count; ++i) { + const auto index = des->unsigned_8(); + const auto amount = des->unsigned_32(); + bill.push_back(std::make_pair(index, amount)); + } + return bill; +} + } // namespace // NOTE keep numbers of existing entries as they are to ensure backward compatible savegame loading @@ -86,7 +106,8 @@ PLCMD_SHIP_EXPLORE = 27, PLCMD_SHIP_CONSTRUCT = 28, PLCMD_SHIP_SINK = 29, - PLCMD_SHIP_CANCELEXPEDITION = 30 + PLCMD_SHIP_CANCELEXPEDITION = 30, + PLCMD_PROPOSE_TRADE = 31, }; /*** class PlayerCommand ***/ @@ -1784,4 +1805,75 @@ fw.unsigned_8(ware_); fw.unsigned_8(static_cast<uint8_t>(policy_)); } + +CmdProposeTrade::CmdProposeTrade(uint32_t time, PlayerNumber pn, const Trade& trade) + : PlayerCommand(time, pn), trade_(trade) { +} + +CmdProposeTrade::CmdProposeTrade() : PlayerCommand() { +} + +void CmdProposeTrade::execute(Game& game) { + Player* plr = game.get_player(sender()); + if (plr == nullptr) { + return; + } + + Market* initiator = dynamic_cast<Market*>(game.objects().get_object(trade_.initiator)); + if (initiator == nullptr) { + log("CmdProposeTrade: initiator vanished or is not a market.\n"); + return; + } + if (&initiator->owner() != plr) { + log("CmdProposeTrade: sender %u, but market owner %u\n", sender(), + initiator->owner().player_number()); + return; + } + Market* receiver = dynamic_cast<Market*>(game.objects().get_object(trade_.receiver)); + if (receiver == nullptr) { + log("CmdProposeTrade: receiver vanished or is not a market.\n"); + return; + } + if (&initiator->owner() == &receiver->owner()) { + log("CmdProposeTrade: Sending and receiving player are the same.\n"); + return; + } + + // TODO(sirver,trading): Maybe check connectivity between markets here and + // report errors. + game.propose_trade(trade_); +} + +CmdProposeTrade::CmdProposeTrade(StreamRead& des) : PlayerCommand(0, des.unsigned_8()) { + trade_.initiator = des.unsigned_32(); + trade_.receiver = des.unsigned_32(); + trade_.send_items = deserialize_bill_of_materials(&des); + trade_.received_items = deserialize_bill_of_materials(&des); + trade_.num_batches = des.signed_32(); +} + +void CmdProposeTrade::serialize(StreamWrite& ser) { + ser.unsigned_8(PLCMD_PROPOSE_TRADE); + ser.unsigned_8(sender()); + ser.unsigned_32(trade_.initiator); + ser.unsigned_32(trade_.receiver); + serialize_bill_of_materials(trade_.send_items, &ser); + serialize_bill_of_materials(trade_.received_items, &ser); + ser.signed_32(trade_.num_batches); +} + +void CmdProposeTrade::read(FileRead& /* fr */, + EditorGameBase& /* egbase */, + MapObjectLoader& /* mol */) { + // TODO(sirver,trading): Implement this. + NEVER_HERE(); +} + +void CmdProposeTrade::write(FileWrite& /* fw */, + EditorGameBase& /* egbase */, + MapObjectSaver& /* mos */) { + // TODO(sirver,trading): Implement this. + NEVER_HERE(); +} + } === modified file 'src/logic/playercommand.h' --- src/logic/playercommand.h 2017-08-16 13:23:15 +0000 +++ src/logic/playercommand.h 2017-09-22 21:01:30 +0000 @@ -846,6 +846,29 @@ DescriptionIndex ware_; Warehouse::StockPolicy policy_; }; + +struct CmdProposeTrade : PlayerCommand { + CmdProposeTrade(uint32_t time, PlayerNumber pn, const Trade& trade); + + QueueCommandTypes id() const override { + return QueueCommandTypes::kProposeTrade; + } + + void execute(Game& game) override; + + // Network (de-)serialization + explicit CmdProposeTrade(StreamRead& des); + void serialize(StreamWrite& ser) override; + + // Savegame functions + CmdProposeTrade(); + void write(FileWrite&, EditorGameBase&, MapObjectSaver&) override; + void read(FileRead&, EditorGameBase&, MapObjectLoader&) override; + +private: + Trade trade_; +}; + } #endif // end of include guard: WL_LOGIC_PLAYERCOMMAND_H === modified file 'src/logic/queue_cmd_factory.cc' --- src/logic/queue_cmd_factory.cc 2017-01-25 18:55:59 +0000 +++ src/logic/queue_cmd_factory.cc 2017-09-22 21:01:30 +0000 @@ -78,6 +78,8 @@ return *new CmdEvictWorker(); case QueueCommandTypes::kMilitarysiteSetSoldierPreference: return *new CmdMilitarySiteSetSoldierPreference(); + case QueueCommandTypes::kProposeTrade: + return *new CmdProposeTrade(); case QueueCommandTypes::kSinkShip: return *new CmdShipSink(); case QueueCommandTypes::kShipCancelExpedition: === modified file 'src/logic/queue_cmd_ids.h' --- src/logic/queue_cmd_ids.h 2017-01-25 18:55:59 +0000 +++ src/logic/queue_cmd_ids.h 2017-09-22 21:01:30 +0000 @@ -70,7 +70,8 @@ kEvictWorker, - kMilitarysiteSetSoldierPreference, // 26 + kMilitarysiteSetSoldierPreference, + kProposeTrade, // 27 kSinkShip = 121, kShipCancelExpedition, === added file 'src/logic/trade_agreement.h' --- src/logic/trade_agreement.h 1970-01-01 00:00:00 +0000 +++ src/logic/trade_agreement.h 2017-09-22 21:01:30 +0000 @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2006-2017 by the Widelands Development Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + * + */ + +#ifndef WL_LOGIC_TRADE_AGREEMENT_H +#define WL_LOGIC_TRADE_AGREEMENT_H + +#include "logic/map_objects/map_object.h" + +namespace Widelands { + +// TODO(sirver,trading): Document everything in here. + +// Maximal number of a single ware that can be contained in a trade batch. +constexpr int kMaxPerItemTradeBatchSize = 15; + +struct Trade { + BillOfMaterials send_items; + BillOfMaterials received_items; + int num_batches; + Serial initiator; + Serial receiver; +}; + +// TODO(sirver,trading): This class should probably be private to 'Game'. +struct TradeAgreement { + enum class State { + kProposed, + kRunning, + }; + + State state; + Trade trade; +}; + +} // namespace Widelands + + +#endif // end of include guard: WL_LOGIC_TRADE_AGREEMENT_H === modified file 'src/map_io/map_buildingdata_packet.cc' --- src/map_io/map_buildingdata_packet.cc 2017-08-20 08:34:02 +0000 +++ src/map_io/map_buildingdata_packet.cc 2017-09-22 21:01:30 +0000 @@ -339,6 +339,7 @@ } } + // TODO(sirver,trading): Pull out and reuse this for market workers. assert(warehouse.incorporated_workers_.empty()); { uint16_t const nrworkers = fr.unsigned_16(); === modified file 'src/scripting/lua_map.cc' --- src/scripting/lua_map.cc 2017-09-18 13:43:08 +0000 +++ src/scripting/lua_map.cc 2017-09-22 21:01:30 +0000 @@ -33,6 +33,7 @@ #include "logic/map_objects/immovable.h" #include "logic/map_objects/terrain_affinity.h" #include "logic/map_objects/tribes/carrier.h" +#include "logic/map_objects/tribes/market.h" #include "logic/map_objects/tribes/ship.h" #include "logic/map_objects/tribes/soldier.h" #include "logic/map_objects/tribes/tribes.h" @@ -594,6 +595,52 @@ } return 0; } + +// Parses a table of name/count pairs as given from Lua. +void parse_wares_workers(lua_State* L, + int table_index, + const TribeDescr& tribe, + InputMap* ware_workers_list, + bool is_ware) { + luaL_checktype(L, table_index, LUA_TTABLE); + lua_pushnil(L); + while (lua_next(L, table_index) != 0) { + if (is_ware) { + if (tribe.ware_index(luaL_checkstring(L, -2)) == INVALID_INDEX) { + report_error(L, "Illegal ware %s", luaL_checkstring(L, -2)); + } + } else { + if (tribe.worker_index(luaL_checkstring(L, -2)) == INVALID_INDEX) { + report_error(L, "Illegal worker %s", luaL_checkstring(L, -2)); + } + } + + if (is_ware) { + ware_workers_list->insert( + std::make_pair(std::make_pair(tribe.ware_index(luaL_checkstring(L, -2)), + Widelands::WareWorker::wwWARE), + luaL_checkuint32(L, -1))); + } else { + ware_workers_list->insert( + std::make_pair(std::make_pair(tribe.worker_index(luaL_checkstring(L, -2)), + Widelands::WareWorker::wwWORKER), + luaL_checkuint32(L, -1))); + } + lua_pop(L, 1); + } +} + +BillOfMaterials parse_wares_as_bill_of_material(lua_State* L, int table_index, + const TribeDescr& tribe) { + InputMap input_map; + parse_wares_workers(L, table_index, tribe, &input_map, true /* is_ware */); + BillOfMaterials result; + for (const auto& pair : input_map) { + result.push_back(std::make_pair(pair.first.first, pair.second)); + } + return result; +} + } // namespace /* @@ -782,7 +829,6 @@ // We either received, two items string,int: if (nargs == 3) { - result = RequestedWareWorker::kSingle; if (is_ware) { if (tribe.ware_index(luaL_checkstring(L, 2)) == INVALID_INDEX) { @@ -803,32 +849,7 @@ } else { result = RequestedWareWorker::kList; // or we got a table with name:quantity - luaL_checktype(L, 2, LUA_TTABLE); - lua_pushnil(L); - while (lua_next(L, 2) != 0) { - if (is_ware) { - if (tribe.ware_index(luaL_checkstring(L, -2)) == INVALID_INDEX) { - report_error(L, "Illegal ware %s", luaL_checkstring(L, -2)); - } - } else { - if (tribe.worker_index(luaL_checkstring(L, -2)) == INVALID_INDEX) { - report_error(L, "Illegal worker %s", luaL_checkstring(L, -2)); - } - } - - if (is_ware) { - ware_workers_list->insert( - std::make_pair(std::make_pair(tribe.ware_index(luaL_checkstring(L, -2)), - Widelands::WareWorker::wwWARE), - luaL_checkuint32(L, -1))); - } else { - ware_workers_list->insert( - std::make_pair(std::make_pair(tribe.worker_index(luaL_checkstring(L, -2)), - Widelands::WareWorker::wwWORKER), - luaL_checkuint32(L, -1))); - } - lua_pop(L, 1); - } + parse_wares_workers(L, 2, tribe, ware_workers_list, is_ware); } return result; } @@ -2762,6 +2783,8 @@ trading over land with other players. See the parent classes for more properties. */ +// TODO(sirver,trading): Expose the properties of MarketDescription here once +// the interface settles. const char LuaMarketDescription::className[] = "MarketDescription"; const MethodType<LuaMarketDescription> LuaMarketDescription::Methods[] = { {nullptr, nullptr}, @@ -5083,6 +5106,7 @@ */ const char LuaMarket::className[] = "Market"; const MethodType<LuaMarket> LuaMarket::Methods[] = { + METHOD(LuaMarket, propose_trade), // TODO(sirver,trading): Implement and fix documentation. // METHOD(LuaMarket, set_wares), // METHOD(LuaMarket, get_wares), @@ -5106,6 +5130,35 @@ ========================================================== */ +/* RST + .. method:: propose_trade(other_market, num_batches, send_items, received_items) + + TODO(sirver,trading): document + + :returns: :const:`nil` +*/ +int LuaMarket::propose_trade(lua_State* L) { + if (lua_gettop(L) != 5) { + report_error(L, "Takes 4 arguments."); + } + Game& game = get_game(L); + Market* self = get(L, game); + Market* other_market = (*get_user_class<LuaMarket>(L, 2))->get(L, game); + const int num_batches = luaL_checkinteger(L, 3); + + const BillOfMaterials send_items = parse_wares_as_bill_of_material(L, 4, self->owner().tribe()); + // TODO(sirver,trading): unsure if correct. Test inter-tribe trading, i.e. + // barbarians trading with empire, but shipping atlantean only wares. + const BillOfMaterials received_items = parse_wares_as_bill_of_material(L, 5, self->owner().tribe()); + const int trade_id = game.propose_trade( + Trade{send_items, received_items, num_batches, self->serial(), other_market->serial()}); + + // TODO(sirver,trading): Wrap 'Trade' into its own Lua class? + lua_pushint32(L, trade_id); + return 1; +} + + /* ========================================================== C METHODS === modified file 'src/scripting/lua_map.h' --- src/scripting/lua_map.h 2017-09-20 21:27:25 +0000 +++ src/scripting/lua_map.h 2017-09-22 21:01:30 +0000 @@ -29,6 +29,7 @@ #include "logic/game.h" #include "logic/map_objects/tribes/constructionsite.h" #include "logic/map_objects/tribes/dismantlesite.h" +#include "logic/map_objects/tribes/market.h" #include "logic/map_objects/tribes/militarysite.h" #include "logic/map_objects/tribes/productionsite.h" #include "logic/map_objects/tribes/ship.h" @@ -1125,6 +1126,7 @@ /* * Lua Methods */ + int propose_trade(lua_State* L); /* * C Methods === modified file 'src/wui/game_message_menu.cc' --- src/wui/game_message_menu.cc 2017-08-17 15:34:45 +0000 +++ src/wui/game_message_menu.cc 2017-09-22 21:01:30 +0000 @@ -487,6 +487,7 @@ case Widelands::Message::Type::kWarfareSiteDefeated: case Widelands::Message::Type::kWarfareSiteLost: case Widelands::Message::Type::kWarfareUnderAttack: + case Widelands::Message::Type::kTradeOfferReceived: set_filter_messages_tooltips(); message_filter_ = Widelands::Message::Type::kAllMessages; geologistsbtn_->set_perm_pressed(false); @@ -580,6 +581,7 @@ case Widelands::Message::Type::kWarfareSiteDefeated: case Widelands::Message::Type::kWarfareSiteLost: case Widelands::Message::Type::kWarfareUnderAttack: + case Widelands::Message::Type::kTradeOfferReceived: return "images/wui/messages/message_new.png"; } NEVER_HERE(); === modified file 'src/wui/ware_statistics_menu.cc' --- src/wui/ware_statistics_menu.cc 2017-08-08 17:39:40 +0000 +++ src/wui/ware_statistics_menu.cc 2017-09-22 21:01:30 +0000 @@ -103,8 +103,7 @@ ®istry, kPlotWidth + 2 * kSpacing, 270, - _("Ware Statistics")), - parent_(&parent) { + _("Ware Statistics")) { uint8_t const nr_wares = parent.get_player()->egbase().tribes().nrwares(); // Init color sets === modified file 'src/wui/ware_statistics_menu.h' --- src/wui/ware_statistics_menu.h 2017-08-18 14:27:26 +0000 +++ src/wui/ware_statistics_menu.h 2017-09-22 21:01:30 +0000 @@ -37,7 +37,6 @@ void set_time(int32_t); private: - InteractivePlayer* parent_; WuiPlotArea* plot_production_; WuiPlotArea* plot_consumption_; WuiPlotArea* plot_stock_; === modified file 'test/maps/market_trading.wmf/scripting/init.lua' --- test/maps/market_trading.wmf/scripting/init.lua 2017-09-18 13:43:08 +0000 +++ test/maps/market_trading.wmf/scripting/init.lua 2017-09-22 21:01:30 +0000 @@ -1,6 +1,6 @@ -include "scripting/lunit.lua" include "scripting/coroutine.lua" include "scripting/infrastructure.lua" +include "scripting/lunit.lua" include "scripting/ui.lua" game = wl.Game() @@ -22,7 +22,17 @@ end end +function place_markets() + prefilled_buildings(p1, { "barbarians_market", 24, 25 }) + market_p1 = map:get_field(24, 25).immovable + connected_road(p1, market_p1.flag, "l,l|", true) + + prefilled_buildings(p2, { "barbarians_market", 29, 25 }) + market_p2 = map:get_field(29, 25).immovable + connected_road(p2, market_p2.flag, "r,r|", true) +end + full_headquarters(p1, 22, 25) -full_headquarters(p2, 32, 25) +full_headquarters(p2, 31, 25) game.desired_speed = 50000 === renamed file 'test/maps/market_trading.wmf/scripting/test_market_can_be_build.lua' => 'test/maps/market_trading.wmf/scripting/test_market_can_be_built.lua' === added file 'test/maps/market_trading.wmf/scripting/test_simple_trade.lua' --- test/maps/market_trading.wmf/scripting/test_simple_trade.lua 1970-01-01 00:00:00 +0000 +++ test/maps/market_trading.wmf/scripting/test_simple_trade.lua 2017-09-22 21:01:30 +0000 @@ -0,0 +1,39 @@ +run(function() + sleep(2000) + place_markets() + market_p2:propose_trade(market_p1, 5, { log = 3 }, { granite = 2, iron = 1 }) + + local p1_initial = { + iron = p1:get_wares("iron"), + log = p1:get_wares("log"), + granite = p1:get_wares("granite"), + } + local p2_initial = { + iron = p1:get_wares("iron"), + log = p1:get_wares("log"), + granite = p1:get_wares("granite"), + } + + -- We await until one ware we trade has the right count for one player. + -- Then, we'll sleep half as long as we already waited to make sure that no + -- additional batches are shipped. Then we check all stocks for the correct + -- numbers. + local start_time = game.time + while p2:get_wares("iron") - p2_initial["iron"] < 5 do + sleep(10000) + end + + sleep(math.ceil((game.time - start_time) / 2)) + + assert_equal(5, p2:get_wares("iron") - p2_initial["iron"]) + assert_equal(10, p2:get_wares("granite") - p2_initial["granite"]) + assert_equal(-15, p2:get_wares("log") - p2_initial["log"]) + + assert_equal(-5, p1:get_wares("iron") - p1_initial["iron"]) + assert_equal(-10, p1:get_wares("granite") - p1_initial["granite"]) + assert_equal(15, p1:get_wares("log") - p1_initial["log"]) + + print("# All Tests passed.") + wl.ui.MapView():close() +end) +
_______________________________________________ Mailing list: https://launchpad.net/~widelands-dev Post to : widelands-dev@lists.launchpad.net Unsubscribe : https://launchpad.net/~widelands-dev More help : https://help.launchpad.net/ListHelp