Canon G1X mk II review: The return of the king?
When I review a camera, I use it for real. Yeah sure, I'll show you and allow you to download full size samples and all, but in order to review a camera, I make it mine first. So throughout the review there will be photographs made and processed my way, along with more "straight" images for you to see what the camera is capable of on it's own.
The camera was supplied by B&H camera. Now, I used to like these people, but now I'm a huge fan. When a camera seller asks you to review a camera, there's always the chance for you to be censored, they wouldn't want you to badmouth a product, right?
Well, not B&H. When they sent me the camera they told me to tell it like it is, good or bad, just be truthful. My friends, I've been burned more than once by sleazy business practices, and it's stores like B&H that remind me that there's still businesses that have VALUES. Amen anyone? No? Yes? Ok let's get that review rollin'.
What's so special about the G1x mkII?
The compact camera market has mostly been targetted towards consumers, the cameras mainly being automatic cameras. The premium compact camera market has been targetted torwards more serious users an pro photographers who do not want to lug around their normal, larger cameras. There's many premium compacts, but one of the most long standing ones is the Canon G series. We are up to G16 and there's bound to be more. These babies have been photographer favorites for the longest time.
The other guy behind Inspired Eye (besides me!!!), Don Springer likes to tell this story: One day while he was riding his motorcycle , he dropped his Canon G9 while the bike was going fast (If you are a cop: He wasn't over the limit for the record!!!) and after spewing out some non-PG words, he went back and picked it up. Screen was cracked but the camera was, and still is kicking strong.
– Guys, I ate too way much last night before sleeping and I had a genius idea when I woke up. How many Canon G's do we have under our belt?
– Instead of making another one….What if we tried to cram a Large sensor inside a G body?
– Woah!!! Killer! Great idea! That's why you are the lead engineer!
– That's right, now get to work!
On paper that was a killer concept. The sensor is large, larger than mu43 sensors and smaller than APSC sensor cameras. Check the free photography course for a refresher on Sensor sizes.
– Guys, who had that great idea for putting a larger sensor in a G body?
– You! You're the best!
– Yeah. But the G1x's AF was too slow, and had a few other problems. So now, we're going to create a G1x mark II, which keeps the same concept, make the camera better looking and improve on everything. Who's the man?
– Of course I am, now get back to work!
*While Canon will deny this conversation ever happened, the NSA begs to differ.
The first G1x on the left had it's inspiration from the Canon G line of cameras, the MKII, on the left looks more "mirrorless" than the original. There's no integrated viewfinder, and it drops the front dial. There's now Wifi and a touchscreen.
The MkII drops 1 megapixel (G1x->14.2 || mkII-> 13.1) but has a longer zoom (G1x-> 28-112 || mkII -> 24-120) and a faster lens (G1x-> 2.8-5.8 || mkII -> 2-3.9) and a shorter distance (from 20cm to 5cm) for macro mode.
In a nutshell, the MkII drops the utilitarian looks and improves on many aspects of the original. But that's on paper. Will the NSA have to listen in to check if the engineers missed the mark and have to create an MkIII? Let's find out….
The Canon G1x size-wise is like an mu43 camera with a lens attached. It really looks like you can detach the lens but you can't. It's much bigger than a compact camera, and not quite pocketable. But if you are like my friend Don, and you would change your wardrobe for the sake of having a larger camera in your cargo pants, than it is "pocketable".
Jeansters like me can forget it. It's not bulky though, and since it provides a lot of features in a compact body, it's really a good size for what it offers.
The camera's first handling is like a handshake, it's that lasting first impression. The G1x didn't disappoint in that area. It doesn't look like a tank, it looks like something fashionable, you'd take it out for casual occasions.
When you handle the camera, you feel that it means business, it's got the guts and mojo inside. The way the grip and the back thumb rest are laid out, the camera is mean to be handled like a compact camera and not a DSLR with a large front grip.
The lens is cool, it has two dials, the first is to (electronically) focus the camera (by default), and the second is to change other settings like Aperture and Shutter speed. The focus ring is smooth, and the control dial is step with good resistance. So you will be able to know how much you turn the dial (it has a tak tak tak step thing) and it's not hard nor too easy to turn, it's really good.
In the thumb-rest area, there's the video record button. If you remember how Sony got heat because the Nex7 users accidentally pressed their video button a lot, you shouldn't worry because the video record is after the thumb rest, so there's no way you will be pressing that thing accidentally.
In the settings you can select what the front ring, the other step ring, the back dial, the shortcut button (below the video button) and even what the video button does. YES. YES and YES. AND you can even select how you want your controls for other modes. So you can assign the dials and all for different functions depending on if you are on Aperture priority, Manual, Shutter Priority, etc.
(Technical talk, click here if you don't understand what I'm talking about). The front dial (the focus ring by default) can be assigned to change the shutter speed or aperture. There's a dedicated Manual Focus button, if you press it, it will override whatever is assign to it to manual focus. Press it again and you are back to whatever you assigned to it.
You can use the second control ring on the lens for Shutter Speed, ISO, Step zoom and other less essential controls like White Balance. You can assign a series of 3 functions that you will rotate trough with the up button. It's dumb but you can't set it to Aperture directly, you have to set it to Shutter Speed + Aperture, and before you shoot, you push up and then it becomes an aperture ring.
Likewise for the dial on the back, you can set up the same controls as above, but it's only one function, you can't cycle through. The shortcut button can be assigned to way too many things to list through, a few notables is White Balance, Drive mode, AF, AE Lock, or you can just turn it off.
The movie record button can be assigned to the same as above, or you can turn it off.
I used the back ring for ISO, the complete front for Shutter Speed and the other ring for Aperture. In a nutshell: It's a JOY to use. There's nothing more important than these 3 controls because they are what control your exposure, and having them at your fingertip like that is essential. If you are a manual shooter like me, the G1x's got it down.
The way the camera is controlled makes this the kind of camera that gets out of your way when making images. It dissapears and it doesn't hinder you. It will require a simple 3-5min setup to make it work the way you want at first but afterwards, it's ON. Those front lens dials are really a great way to work.
If you are an automatic shooter, than yes, this can be a one handed camera, but for folks who want control, it's a two handed camera.
There's a dedicated Wi-Fi button that is on top of the thumb rest, ready for your wi-fyin' and Instagramin'. I won't be talking about the Wi-Fi because you get it. You can transfer images directly to your phone or computer. It's a cool feature and you know what it means, no need to spend time on it.
The flash pops up and with your index finger you can even bounce the flash a bit. If that's not enough, the camera has a normal hot-shoe for your regular speedlights and strobist needs.
Speaking of flipping. the LCD has so many positions that Canon should write a book called LCD Camera Sutra or something.
I can hear it, it's NOT f/2 throughout the zoom range. I know I know. If we negate the f/3.9 and take it as an f/4 lens. It's only 2 stops from f/2. Look, it's pretty much a feat to cram such a large sensor in the camera and have a nice zoom range, so it's really a good place to be in my opinion if it's 120mm at f/4.
When it comes to the wide angle part, there's distortion going on (fixable in Lightroom), just look at the white wall on the left, second image up. I like perspective distortion, makes things larger than life, but only you know what your needs-wants are.
The camera's LCD can flip up completely, meaning this can become a selfie camera. Couple that with the Wi-fi and you can create selfie jealousy on Instagram. As you can see the G1x can also Bokeh enough.
Ah, the BIG one. I usually brush this off by saying "Any camera is good enough". But this camera has very high expectations on it because of it's sensor size. So, does it give you DSLR/Mirrorless-like image quality? Or does it give you smaller camera-like quality? I'll show you some straight images to decide for yourself and then make a few comments.
Click the image above for 100% view. For me, I'm comfortable to around ISO800…. Click here to download ALL and play around.
If you are serious about photography but do not want the stuff associated with DLSRs, Mirrorlesses and lenses, this camera might fit the bill real well.
P.S: If you want some heavenly coffee at Starbucks and feel bad for all the carbs, ask for a Skinny Mocha!!!
Fringes too (look at the coffee cup borders). Just pay attention to that and slightly change your angle if that happens.
The RAW files coming out of the G1X are sort of bipolar when it comes to editing them. In the two examples below, I maxed out the shadows on the left.
Look the the line on the left as an indicator of movement. Things get shaky at half a second when you are not moving, which is pretty darn good. If you think that the zoom's 3.9 is a bit too slow, I think this more than makes up for it.
The screen, while bright and nice, is not the highest resolution and crispest around. Sometimes I have a hard time seeing what's actually in focus if it's close enough. It's overall a bit fluffy. Using the optional viewfinder is better. The screen cannot be turned into BW. Yep beats me. The only way to turn it into black and white is to put it in JPG only mode. It will not let you put it in BW even if you are in RAW+JPG mode. That's very much odd in my opinion. The viewfinder is very nice. It's not THE highest resolution but more than ample enough for prolonged viewfinder shooting.
I like putting it up and look into it face down. It will not fall off since you have to press a button in order to release it. When the viewfinder is up and the screen is flipped up, the screen sensors might pick it up and turn the screen off (and the viewfinder on). So if you have the screen up, it's better to put the viewfinder off too. The viewfinder is well designed and looks part of the camera. There's one too many viewfinders that look like afterthoughts….
Being a touchscreen, you can do the smart-phone thing and press to make the image. Nifty if you put the camera someplace that your finger can't reach!
Ok, the biggest problem with the first G1X is the fact that the autofocus was so slow. That's not the case for the G1X, the AF is normal and it occasionally focus hunts in lower light but it's not the end of the world. There's a dedicated Manual Focus button and a AF-MF mode where you can autofocus and then fine tune with MF after a half press.
There's also a mode where you can Manual Focus and then half press for AF to fine tune the focus. Really cool stuff in my opinion. In MF, you can always have a zoom so that you can see your focusing action. There's even a focus peaking mode! You can change the color and strength. Unexpected but cool. It's not going to scream at you if it's peaked, it's more of a gentler and softer focus peaking. It's even better when it's zoomed in and peaked.
Shows the stabilization capabilities too as I was walking. There's some jello effect when the camera moves too fast. It's good for the occasional video, not if you are a pro.
The G1x is equipped with an integrated ND filter that cuts down the light. Look, I live in Florida, and if you know anything about Florida it's that it's SUNNY. That ND cuts light by 3 stops, so out in a very sunny day in a very sunny state, I was able to get 1/13th of a second at f/16. Under certain conditions that can be very good for landscape shooters!
I pull the LCD out and put the camera aound my chest area, I use it as a sort of Twin Lens Reflex. With the lens at 24mm, I put my aperture at f/8 and put my ISO at around 200-400 (I'm in Florida, remember?). I put the camera in manual focus mode and focus at 1 meter (It's not really precise, only a few distances are marked). Anything from half a meter to infinity will be in focus.
The lens dials really come alive during that time, you just go out for some street photography, your finger is on the ring to change your shutter speed, you just focus on making images and adjusting your shutter speed as the light changes. It's a killer street setup, easy to use.
Without a doubt, resisting the temptation to compare, the Canon G1x is a great camera. It gets out of your way and gives you the controls to make nice images. The biggest problem is the fact that Canon is not alone in the camera arena, and this camera is priced at $799.
It's for all intents and purposes a great camera, but it's possible to find other options at that same price point. It's a great light travel camera that would fit all travel needs and performs VERY well in the streets. It's a fun camera with some quirks that can output some very nice images. It's a nice step up from your P&S or a nice companion to a larger camera.
Interesting camera. I like your street work here Olivier.
Great review Oliver I always like your style!.
Should I consider this over the Fujifilm X20 ?…..I know I know the sensor is way larger on the Canon but the Fujifilm does have a cracking fast lens , is small and has a very good viewfinder in a compact body…..and not least of all Don loves using his!.
Well Don is drooling over this one too 🙂 The thing about the Canon G1x is that you get more camera for the same size compared to the X20. I hesitate even comparing them because they are in different categories of sensor sizes.
The viewfinder in the X20, just for the record is NOT the same you will find in the Fuji X100s or X pro 🙂 , so not EVF-OVF switchin' .It's pretty cool and has a few info in it.
How would you rate image quality compared to Sony's RX100 mk2?
As with the other somewhat negative reviews of the G1X-2, I technically agree with most of the criticisms as such. What I don't agree is that there are other, cheaper options. Please tell me: if I need a compact camera with a foldable lens that is roughly equivalent to a 24-120mm 2.0-4.0, and with a sensor that lets me work at ISO 1600 as well, what is that camera that is the better alternative? If you tell me that, I'll buy it immediately. Otherwise, I have to accept the quirks of the G1X-2 and keep on using it.
Hehe I don't think this review was a negative one 🙂 There's a few issues but it's still great. Looking at it from your needs, 24-120 f2-f4, ISO1600, I don't think there's alternatives. I don't know about the 1 inchers like the RX100 how they perform at that ISO.
' The screen cannot be turned into BW….'. That's very sad. Does anyone know if this is possible with the G1X MI, when shooting RAW?
Just came across your site and read this. Your work is amazing… love it.
However, I know this question had been asked to death, but I am still not getting any answers, I like both the G1XmkII and the Sony RX100ii… And is in the market, like forever, trying to decide which one to get. In your opinion, which one do you like more?
@Tiew – It's pretty tough to make a choice with the slew of premium compacts out on the market these days. I own both cameras and, while the Sony RX100 II is smaller, lighter and truly pocketable, the new G1X mII looks to manage and have an upper edge in IQ. I particularly make a lot of use of the extended dynamic range options from the menu to pull gorgeous skies. I got stunning images that way. Well, for me another reason to go with the G1X was that I owned some Canon flashes already and you can use those in the hotshoe, Apart from these reasons, the RX100 ii is not a bad choice, especially if you're also looking to save some cash.
In the flood of comments, I didn't see this comment, sorry!
I came across your website while searching for Canon G1X Mark II reviews. Your review was the most enjoyable because it was so unbiased and personal. I'm now the proud owner of a G1X II and I love it. I upgraded from a G15 and I like the chunky feel of this new baby. Even with a Speedlite 430EX III RT mounted on it, the combo feels pretty lightweight and easy to carry on my trips. Picture quality is good (I shoot mostly JPEG) and although there's a dreamy quality when shot wide open, that can be used to my advantage.
Keep up the good work, Olivier!
Brilliant review. It is moving me towards buying one which is available at good discount in india. Flash Sync+ND will do many a tricks outdoors.


Home Resources Guest Features Nollywood After TIFF 2016: What Next?
Welcome to the second edition of #SafeHouse Sessions podcast as hosted by Emil Biobelemoye Hirai-Garuba. This episode's topic: "Nollywood After TIFF 2016: What Next?" featuring filmmaker Ishaya Bako, Taiwo Shittu and Ummi Yakubu. Safe House Sessions is a podcast where a couple of Nigerian film enthusiasts discuss movies, art, and entertainment in general.
Previous articleCheck Out Africa's Newest Authors: SSDA Prize Longlist Is Out!


Lenny & Larry's NEW bite-sized Complete Crunchy Cookies are like no other cookie on the market! Snack friendly and convenient in a resealable pouch, you won't believe the amount of clean plant-based protein and fiber they contain. With the most addicting crunch and incredible taste, you can feel great about finishing the whole bag!


text:
Jim Irsay addresses "difficulties" with Carson Wentz over injuries and lack of vaccination
Posted by Mike Florio on September 26, 2021, 9:17 AM EDT
Colts owner Jim Irsay has had a string of bad luck with quarterbacks, dating back to the sudden and abrupt retirement of Andrew Luck. Irsay currently has a starting quarterback who is injury prone or, more accurately, prone to putting himself in position to get injured. Speaking at a Saturday night event, Irsay vented a little frustration about Carson Wentz.
In comments published by Fox 59, Irsay addressed whether Wentz will play in Tennessee after suffering a pair of sprained ankles last Sunday against the Rams. Irsay then deviated into a related, and controversial, subject.
"I know he wants to go," Irsay said. "I know how hard he's worked. I know he's respected in the locker room, and all those sorts of things. But, you know, he has to stay healthy. You know, no one's hiding behind the M.O. of the past. That was there, and you know since we you know came here and going through the months we've been through, there's been difficulties. And it also makes it difficult if you're not vaccinated, you know, because it makes it harder to depend on someone if they're not vaccinated. So they have freedom of choice and we understand that. But, you know, we — we're hoping he can come back and play well. I think he'll be a game-time decision."
During the preseason, Wentz missed five days after having close contact with an infected person. He spoke at length about his decision not to be vaccinated, but he never shed any real light on the substance of the decision to put himself in position to be tested every single day (missing a minimum of 10 days if positive) and to potentially be knocked out for five days in the event he's in sufficient proximity to someone who tests positive.
The Colts are among several teams with a cluster of key players who have refused to be vaccinated. The fact that Irsay raised the topic shows that he and the organization have frustration regarding the obvious competitive issues it creates, especially when most other teams have all or most of their players vaccinated.
Separate from the vaccination question is Wentz's propensity to be injured. All too often, he tries too hard to get more out of a play, putting himself in position to be hit. His right ankle sprain happened when he fought to extend things long enough to throw a pass. In so doing, Wentz found himself dragged down from behind by Rams defensive tackle Aaron Donald. That event in and of itself entails a real injury risk.
While there's no questioning Wentz's toughness, he needs to know when to call it quits on a given play. Get down, throw it away. Avoid taking the hit. That extra yard or two or split second to try to find a receiver or whatever won't matter if he ends up missing days, weeks, months due to his latest injury.
The challenge for the Colts becomes coaching that tendency out of him. The challenge for Wentz becomes incorporate that coaching into his game, or he won't be playing quarterback much longer in the NFL.
27 responses to "Jim Irsay addresses "difficulties" with Carson Wentz over injuries and lack of vaccination"
rockemsockemrobots says:
Man, good riddance to Wentz from Philly. He isn't a bad person or anything… he just… sucks. Odds at that 1st round pick sure seems slim.
saintsgotrobbedandwereinthesameboat says:
Honestly if you dont get the jab i dont and wont feel bad for you
It's a TRAP! says:
My body. My choice. Consult your doctor about your own health decisions.
bostonbeans says:
Wentz is the weak link in Indy chain.
turkeycreekjackjohnson says:
Call Andrew Luck. See if he's bored with the early retirement. He's probably healed up enough by now.
saint4life says:
Not a leader!!
bananas says:
Eagles used the 3rd round pick to move up to draft smith so worse case they get a 2nd round and smith for wentz
brownsfan says:
The Colts traded for Wentz knowing he was not vaccinated. Maybe that should have been discussed before the trade.
commentsareforclosers says:
Sounds like what a Cam Newton thinks you get when you get a Cam Newton?
12jjc says:
Finally someone with authority calls out Wentz for his reckless play. Way to go Irsay!
grant35 says:
Wentz just seems like a knucklehead. He seems totally discombobulated during interviews, and tries to extend plays WAY too long.
shurmanblog says:
It's clear once again , you don't have to be very smart to make huge money playing in the NFL and Wentz is just one of many who prove that point .
34patfitz says:
Trust him.
(He'll be a backup soon)
bird2urmother says:
I agree. Luckily the dolphins 1st rounder we have is looking better each week for us.
miemsaha says:
Wentz and players like him who decide not to get vaccinated are not team players. Even Aaron Rodgers was vaccinated…
loldeepball says:
Eagles fan here who watched Wentz very closely for his whole career: the real problem with Wentz is he isn't an effective pocket passer. Per Pro Football Focus, his passer rating from the pocket is backup quarterback level. He doesn't read defenses well or throw with timing or anticipation.
Where he does excel is when he throws from outside the pocket to buy time for his receivers to gain a lot of separation so he doesn't need to read the defense or throw with timing/anticipation. For the first few years of his career, that was mostly fine because his passer rating outside the pocket was crazy high.
But because of his shortcomings as a pocket passer, he NEEDS to extend plays to be a viable NFL starting quarterback. Now that he's lost some speed because of all the injuries and continues to suffer injuries at an alarming rate, whether he can continue to play the style of football he needs to in order to succeed is an open question.
tigershark0052 says:
It was obvious trading for Wentz was a mistake before it even happened, when it was still a rumor.
dlw492 says:
Wentz is that kid in High School that got all C's and became a Surgeon General. Then got their license taken for malpractice.
iloveyouneighbor says:
The Colts fell apart at the seams. What they need is to gather the fellas for a team-building exercise, say, knitting another participation banner together.
They still have the greatest play in NFL history, and that can never be taken away from them: The Funt. Classic.
redclaw1314 says:
This is a rare vibe from the owner. Usually Colts management speaks over the top good about their players and team.
He wasn't well liked in the Philly locker room…hard to see him as a beloved leader in Indy, especially now with the owner calling him out. Best ability is availability. Even if he is 100% healthy, you can not be with any confidence that he will play every Sunday due to him being not vaccinated.
just1an2average3sports4fan says:
There is plenty of blame to go around here, especially concerning Wentz. I attended both Colts games. This highly-paid, highly-hyped offensive line hasn't done their job up front. Wentz is running for his life. Horrible red zone success, especially short yardage. Ryan Kelly has his money, Braden Smith has his money, and Nelson is about to get huge money. Right now this line is more like overpaid and over-hyped. They haven't done their jobs, either.
pbdisciple says:
Brittle, mentally and physically. But he has that pile of dough to fall on to cushion the blow. Game used to be more physical for the QB (in how and when they could be hit), but guys are being hit harder today (size and speed). Can't fault a guy for fighting for yardage, but he also has to know he is brittle and act accordingly. Goff/Wentz/Watson are three worst contract extensions of recent time. Nowhere near enough track record to warrant any of them.
hu1khogansays says:
The o-line will magically play well again once Wentz is out of the lineup… Eagles fans have been through this time and time again. Youtube "Eagles Offensive Line Gives up Mid Play". That'll tell you all you need to know about what Wentz's teammates think about him.
2020vision says:
Not true. They also gave up the #12 pick in the draft to get Smith. That pick was used to draft Micah Parsons, who last week was dominant with a sack, 8 pressures and 4 quarterback hits. If you lump those two deals together like you did, if Parsons ends up better than Smith – at arguably a more valuable position – the net effect is they gave up Wentz for pretty much nothing.
winged warrior says:
Eagles fan here ….
Sounds like what happens to the majority of the 'running QB's' in the league .
iowahbr says:
A real head case

redpajama_set_name: RedPajamaCommonCrawl


text:
Беспосадочный перелёт Севастополь — Архангельск — беспосадочный перелёт, совершённый экипажем советских женщин-авиаторов на гидросамолёте МП-1.

История

Экипаж военных летчиц в составе: командир — старший лейтенант Полина Осипенко, второй пилот — старший лейтенант Вера Ломако и штурман-радист — лейтенант Марина Раскова 2 июля 1938 года совершили рекордный беспосадочный перелёт по маршруту Севастополь — Киев — Новгород — Архангельск длиной 2416 км (по другим данным — 2372 км, по прямой 2241,5 км) — за 10 часов 33 минуты. Самолёт приводнился на Холмовское озеро.

Лётчицы направили телеграмму И. В. Сталину:

«МОСКВА, КРЕМЛЬ ИОСИФУ ВИССАРИОНОВИЧУ СТАЛИНУ.

Беспосадочный перелет Севастополь-Архангельск выполнен. Готовы выполнить любое Ваше задание. Осипенко, Ломако, Раскова».

На следующий день газета «Правда» писала в передовой статье:

«Вслед за Владимиром Коккинаки и Бряндинским блестящий успех выпал на долю трех советских летчиц — Осипенко, Ломако и Расковой. В самом характере советского народа заложены прекрасные свойства, необходимые для того, чтобы быть отличным летчиком. Упорство, мужество, несокрушимая энергия заложены в советском человеке, воспитаны партией... Каждый год советская авиация добивается новых и новых побед, ставит новые и новые рекорды».

Награды
 За этот перелёт все участники были награждены орденом Ленина.

Замечание

Полет прошел вдоль Астрономо-геодезической дуги Струве, координаты которой послужили астрономическими ориентирами в полете.

Примечания

Ссылки
 Перелёты отечественных и зарубежных лётчиков
 Летчицы М. Раскова, П. Осипенко и В. Ломако, совершившие беспосадочный перелет
 ГЕРОИЧЕСКИЙ ПЕРЕЛЕТ - Это было в 1938 году

События в Севастополе
События в Архангельске
1938 год в Севастополе
События 2 июля
Июль 1938 года
1930-е годы в Архангельской области
Беспосадочные перелёты

redpajama_set_name: RedPajamaWikipedia


As the premier provider of arboricultural services, Ace is the most trusted name in healthy trees and shrubs. Our knowledge and expertise has enabled us to provide clients with the highest level of care since our beginnings. At Ace we understand that the trees and shrubs surrounding your home are your personal piece of nature. That's why we take a natural approach to the care of your surroundings.
The certified arborists and technicians at Ace are dedicated to every property under their care. Each of them is a friendly and knowledgable source of information about the tree and shrub species that compromise your landscape.
Ace have access to the latest techniques, tools and products to deliver state-of-the-art plant health care. Call Ace to discuss the appropriate tree and shrub care program for your landscape objectives.
Ace's approach to tree and shrub preservation is tailored specifically to your landscape's plant material. With our program, your trees and shrubs are monitored throughout the growing season and early fall for signs of insect damage, disease and environmental stress. Existing conditions are treated on the spot. Insect populations are kept from growing to unmanageable levels. Diseases are caught in their early stages. Environmental stresses are identified before damage becomes permanent. Beneficial insects (insects that prey on pests) are left undisturbed and the amount of toxins used is vastly reduced.
Ace's program is an environmentally conscious approach to tree and shrub health care and preservation.


text:
Update: Canada bans commercial puppies imported from Ukraine
The Canadian Food Inspection Agency (CFIA) has cancelled import permits for commercial puppies, travelling from Ukraine, that are less than eight months old .
The move follow the CFIA's investigation into the death of 38 French Bulldog puppies in the hold of a flight from Kiev to Toronto on June 13, which was operated by Ukraine International Airlines (UIA).
In a statement released earlier this week, the CFIA explained: "The CFIA takes its mandate for protecting animal welfare very seriously.
"Effective July 6, 2020, the CFIA will no longer issue new permits for the importation of commercial dogs under 8 months of age from Ukraine.
"The CFIA's decision is based on investigation findings of possible failures to comply with import requirements, including animal welfare concerns. These actions will remain in effect until the CFIA is satisfied that import conditions and international transport standards are in place and that animals will travel safely in the future."
Animal Transport AssocitaionUkraine International Airlines
Latest airlines news
Cargo airlines are amongst the signatories of a letter urging immediate action on 5G plans in the US to "avoid…
Air France KLM and Envirotainer partner on emissions
Temperature controlled ULD firm Envirotainer has joined Air France (AF) KLM Cargo's sustainable aviation fuel (SAF) programme as the carrier…
Transport Canada Civil Aviation has approved conversion firm Aeronautical Engineers, Inc's (AEI) Supplemental Type Certificate (STC) for its 12-pallet position…


text:
typedef NS_ENUM(NSUInteger, STPopupTransitionStyle) {
    STPopupTransitionStyleSlideVertical,
    STPopupTransitionStyleFade
};

@interface STPopupController : NSObject

@property (nonatomic, assign) STPopupTransitionStyle transitionStyle;
@property (nonatomic, assign) CGFloat cornerRadius;
@property (nonatomic, strong, readonly) STPopupNavigationBar *navigationBar;
@property (nonatomic, assign, readonly) BOOL presented;

- (instancetype)initWithRootViewController:(UIViewController *)rootViewController;

- (void)presentInViewController:(UIViewController *)viewController;
- (void)presentInViewController:(UIViewController *)viewController completion:(void (^)(void))completion;
- (void)dismiss;
- (void)dismissWithCompletion:(void (^)(void))completion;

- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated;
- (void)popViewControllerAnimated:(BOOL)animated;

@end

redpajama_set_name: RedPajamaGithub


Writer, presenter and Great British Bake Off winner Nadiya Hussain will deliver the opening keynote interview at this year's Children's Media Conference, which takes place 2-4 July in Sheffield.
Ahead of the publication of her first children's picture book My Monster and Me this October, at CMC Nadiya will tackle mental health, intolerance, inequality and consider how to inspire young people to feel truly limitless. Nadiya's willingness to openly discuss her lifelong struggle with panic disorder has struck a chord with audiences and propelled the 2015 baking show winner to national treasure status.
Nadiya's keynote interview comes as part of this year's overall theme of Limitless, and will explore what kids' media can do to support the limitless curiosity, imaginations and entertainment appetite of children, while making sense of the near-limitless choice and connectivity available to them.
The conference will offer visitors a no-limits exploration of the unprecedented changes facing the industry, including new players and new funding models, new thinking on inclusivity and engagement, and pan-platform brands with limitless ambitions to connect with kids.
Now in its 16th year, organisers expects to welcome more than 1,200 delegates to this year's CMC, kicking off with the annual market day, the CMC International Exchange, on Tuesday 2 July.


By Marica I found this cheery little (modern) Victorian bathroom while trawling Pinterest, one of my new obsessions hobbies. So we decided to do our own version of "GET THE LOOK" (hopefully the first of many)!
5. Temple Figurine – Woolworths (If, like me, you don't really do the whole temple figurine thing; Woolworths also has some lovely big ceramic candle holders (get a couple and group them) find them: here).
ADD THIS: Add a splash of colour with a beautiful potted orchid.
And there you have it! Our very first Get The Look! P.S. Come find Rose and myself on Pinterest to see what products, images and ideas we love.
Hi Rose. I love this look. I am currently working on two blue inspired homes. This is great inspiration.
Hi Lelanie – although our opting for a 'blue' themed bathroom was purely co-incidental, I noticed that blue was the colour that most people were working with. Cool, calm thinking spaces must be the order of the day.
Sumaiya, painting a cast iron bath can be quite the mission. First prize would be to hire a professional tub re-finisher. If you are going to do it yourself preparation is very important – use either an oil-based primer followed by the emulsion paint, or an oil-based / enamel paint on its own. Check out this website for more info: DIYdoctor. I am not quite sure about the colour. If you are from South Africa check out the Plascon website or any hardware store for a selection of paint swatches and choose a colour you are happy with.


It takes considerable effort to botch a management strategy game on mobile, but Cheerdealers finds a way with Distrust, one of the worst mobile ports in a while.
This was supposed to be a review of Distrust, the well received strategy-survival indie spiritual successor to James Cameron's The Thing. It's a genuinely neat game that melds ideas from the likes of Don't Starve with a real-time management layer. You guide the last two to three survivors of a helicopter crash to investigate a seemingly abandoned military research base in the arctic and swiftly learn that things are awry. So far, so wonderfully creepy. That is, if you can actually manage to play it proper on your phone. You see, Distrust first came out on PC, and my goodness does it show because there seems to be absolutely no effort made to work with the new platform.
Icons are the size of ants, and often times you won't even know what they do until after pressing them.
Icons are the size of ants, and often times you won't even know what they do until after pressing them. Distrust's free tutorial level certainly helps you get a feel for what all might be experienced, but the game itself seems to expect you'd be able to drag a mouse cursor over every icon to be informed what functions they serve. Also, because every action is so hard to make out, regardless of if you use the zoom function or not, you'll often find yourself sending commands you never intended. Just swapping between your party can be an utter nightmare of one thinking you're telling them to cancel their current task while you were just intending to check in on the other while they were doing something.
It's not that the gameplay itself is bad, but this mishandling makes a joke of ever playing this game on mobile.
Now, you might think maybe if you hold down your one finger, a small cursor might appear, or a magnifying glass might make it easier to choose and decide, but you'd be wrong. In fact, the only reasonable way to even move around the map to survey everything is to open the minimap and tap in there to quickly jump wherever you need to be looking. This leads to an extremely tedious experience trying to get your team to execute your plan properly. It's not that the gameplay itself is bad, but this mishandling makes a joke of ever playing this game on mobile. I never experienced combat, but I have to imagine it plays even worse if basic exploration is any indication.
Worse still, as I said, while there is a free tutorial, the actual meat of the game is locked off. In fact, a lot of it is obfuscated with microtransactions, and while I haven't played the PC port, you can be pretty certain it's not nearly this bad on there. I understand the need to monetize your game – developers need to eat too – but the main hook of the game hasn't even been fully introduced yet. You don't end your demo before the exciting thing happens; it should end right as things are getting good. Then there's incentive to put down the few dollars to see more. Combine this abrupt cutoff with the lackluster controls, and I can think of few worse introductions a game could offer to prospective buyers. It feels like this port was handled with little to no research or quality assurance, which shouldn't be the case given how well it's been received on PC.
You can't just slap a game on mobile, something developer Cheerdealers needs to understand.
One can only hope Distrust gets its act together in the future, but first impressions are everything. Being free-to-start might net it plenty downloads, but that doesn't mean half as many players are going to stick around. You can't just slap a game on mobile, something developer Cheerdealers needs to understand if they intend to make a place for themselves on the appstore.


text:
Home » Uncategorized » May God Save Haiti
May God Save Haiti
With the introduction of Democracy in Haiti in 1991, the Haitian people were duped into believing that two centuries of repression at the hands of unsavory strongmen would be a thing of the past, a fallacy that turned out to be a recurring nightmare for which there seems to be no cure. Even the people's right to elect leaders of their own choosing is not protected under the present conditions. Under the widely publicized and accepted version of the ills afflicting Haiti, one man, Jean Bertrand Aristide, twice elected by the people and twice evicted from power by the now-defunct Haitian military (1991) and a US-French invasion (2004) has been personally held responsible for the current state of affairs. Naturally this orchestrated propaganda campaign, meant to obscure the true role of economic liberalism, which accelerated the country's descent into wretched poverty and despair beginning in the mid-1980's, had had the desired effect.
Although kidnappings, gross human rights violations, political violence, systemic corruption, drug trafficking and armed gangs were a fact of life for millions of Haitians during the military dictatorships that ruled the country from 1986 through 1994, Aristide is invariably blamed for their introduction in Haiti. For two centuries, the impoverished masses' dream of participating in the political and economic life of the country was an integral part of their existence, yet the man is somewhat blamed for having encouraged the pursuit of this inalienable right. Though the man was simply trying to hold the line against the forced induction of a not-yet-ready Haiti into the unforgiving enceinte of economic liberalism, he is accused of having unnecessarily provoked the wrath of the international community through his supposedly narrow-minded political skills and vision.
These help explain the exclusion of his political party in the last two elections and the concerted resolve of the ruling elite and the international community to keep the man from returning to his native soil before the March 20th run-off, although his presence will not change the dynamic of the present order. Some cynics even attributed the calamities that befell Haiti since Aristide's forced exile in 2004 (hurricanes, earthquakes and the cholera epidemic) to his supposedly demonic powers, an illogical assertion that has become a credo among Aristide's legions of enemies. Meanwhile, the saga of a cornered, impoverished and humiliated nation continues while those in charge are either oblivious of the reality or unwilling to change course.
On March 20th, in the most delicate moment in the history of their nation, millions of Haitians go to the polls to elect a successor to René Préval, a man whose tenure at the helm of the impoverished nation will be summarized by future historians as the apex of incompetence and indifference. Unfortunately, the electorate' desire to move away from the status quo may not be forthcoming in that election because of the vile actions of the current regime, whose attempt at prolonging its rule by proxy resulted in the improper and heavy handed meddling of the international community. Rather than vote for a leader of their own choosing, the Haitian people will therefore be dealing with the ghost of their storied and turbulent past in the upcoming run-off pitting Michel Martelly, a sociopath and Kompa singer, and Myrlande Manigat, a tantrums-prone Grande Dame of Haitian politics.
With so much at stake, even the often-used terminology "none of the above" does not come close to describing the stark choices facing the electorate on March 20th. The cynicism warped into populism of Michel Martelly and the unreconstructed elitism of Myrlande Manigat no doubt do not coalesce with the aspirations of the electorate that is being pushed into a corner. Basically, the Haitian people are called upon to participate in a masquerade whose only claim to legitimacy and fairness remains its approval by the international community. The consolation prize would neither be economic development nor stability, but an implied understanding that whomever they choose on that fateful day will not be spirited out of the country for insubordination to the power of the status quo before the end of his or her term. Accordingly, it will be politic as usual and the despair that characterizes their existence over the last 25 years will continue to endure under the watchful presence of thousands of UN soldiers of the MINUSTAH.
This inflexible stance of the elite, the arrogance of the international community, the myopic attitude of the political class, the apathy of the middle-class and resilience of the poor are contributing factors that allow the repugnant system to endure. Arrogance, the most conspicuous symbol of power, makes these people oblivious of the danger looming on the horizon. Inevitably, the wind of change presently blowing against organized repression and entrenched dictatorships, so prevalent in many areas of the world, will reach the shores of Haiti and jolt its people out of their stupor. Indeed, it is inconceivable that the destiny of a country which, 207 years ago, said "no to organized repression and arrogance" could be so cavalierly handled by an ever expanding group of native sons and daughters doing the bidding of sworn enemies of the nation either willfully or unknowingly.
The election of Michel Martelly which, thus far, seems a probability rather than a possibility due to the dullness and elitist posture of Myrlande Manigat, his opponent, will bring ridicule to the a nation already burdened by a long list of idiosyncrasies. In this hour of desperation, may God save the nation.
ddjougan@gmail.com

May God Save Haiti

by The Haitian Times, The Haitian Times
May 5, 2012

 With the introduction of Democracy in Haiti in 1991, the Haitian people were duped into believing that two centuries of repression at the hands of unsavory strongmen would be a thing of the past, a fallacy that turned out to be a recurring nightmare for which there seems to be no cure. Even the people's right to elect leaders of their own choosing is not protected under the present conditions. Under the widely publicized and accepted version of the ills afflicting Haiti, one man, Jean Bertrand Aristide, twice elected by the people and twice evicted from power by the now-defunct Haitian military (1991) and a US-French invasion (2004) has been personally held responsible for the current state of affairs. Naturally this orchestrated propaganda campaign, meant to obscure the true role of economic liberalism, which accelerated the country's descent into wretched poverty and despair beginning in the mid-1980's, had had the desired effect.
 Although kidnappings, gross human rights violations, political violence, systemic corruption, drug trafficking and armed gangs were a fact of life for millions of Haitians during the military dictatorships that ruled the country from 1986 through 1994, Aristide is invariably blamed for their introduction in Haiti. For two centuries, the impoverished masses' dream of participating in the political and economic life of the country was an integral part of their existence, yet the man is somewhat blamed for having encouraged the pursuit of this inalienable right. Though the man was simply trying to hold the line against the forced induction of a not-yet-ready Haiti into the unforgiving enceinte of economic liberalism, he is accused of having unnecessarily provoked the wrath of the international community through his supposedly narrow-minded political skills and vision.
 These help explain the exclusion of his political party in the last two elections and the concerted resolve of the ruling elite and the international community to keep the man from returning to his native soil before the March 20th run-off, although his presence will not change the dynamic of the present order. Some cynics even attributed the calamities that befell Haiti since Aristide's forced exile in 2004 (hurricanes, earthquakes and the cholera epidemic) to his supposedly demonic powers, an illogical assertion that has become a credo among Aristide's legions of enemies. Meanwhile, the saga of a cornered, impoverished and humiliated nation continues while those in charge are either oblivious of the reality or unwilling to change course.
 On March 20th, in the most delicate moment in the history of their nation, millions of Haitians go to the polls to elect a successor to René Préval, a man whose tenure at the helm of the impoverished nation will be summarized by future historians as the apex of incompetence and indifference. Unfortunately, the electorate' desire to move away from the status quo may not be forthcoming in that election because of the vile actions of the current regime, whose attempt at prolonging its rule by proxy resulted in the improper and heavy handed meddling of the international community. Rather than vote for a leader of their own choosing, the Haitian people will therefore be dealing with the ghost of their storied and turbulent past in the upcoming run-off pitting Michel Martelly, a sociopath and Kompa singer, and Myrlande Manigat, a tantrums-prone Grande Dame of Haitian politics.
 With so much at stake, even the often-used terminology "none of the above" does not come close to describing the stark choices facing the electorate on March 20th. The cynicism warped into populism of Michel Martelly and the unreconstructed elitism of Myrlande Manigat no doubt do not coalesce with the aspirations of the electorate that is being pushed into a corner. Basically, the Haitian people are called upon to participate in a masquerade whose only claim to legitimacy and fairness remains its approval by the international community. The consolation prize would neither be economic development nor stability, but an implied understanding that whomever they choose on that fateful day will not be spirited out of the country for insubordination to the power of the status quo before the end of his or her term. Accordingly, it will be politic as usual and the despair that characterizes their existence over the last 25 years will continue to endure under the watchful presence of thousands of UN soldiers of the MINUSTAH.
 This inflexible stance of the elite, the arrogance of the international community, the myopic attitude of the political class, the apathy of the middle-class and resilience of the poor are contributing factors that allow the repugnant system to endure. Arrogance, the most conspicuous symbol of power, makes these people oblivious of the danger looming on the horizon. Inevitably, the wind of change presently blowing against organized repression and entrenched dictatorships, so prevalent in many areas of the world, will reach the shores of Haiti and jolt its people out of their stupor. Indeed, it is inconceivable that the destiny of a country which, 207 years ago, said "no to organized repression and arrogance" could be so cavalierly handled by an ever expanding group of native sons and daughters doing the bidding of sworn enemies of the nation either willfully or unknowingly.
 The election of Michel Martelly which, thus far, seems a probability rather than a possibility due to the dullness and elitist posture of Myrlande Manigat, his opponent, will bring ridicule to the a nation already burdened by a long list of idiosyncrasies. In this hour of desperation, may God save the nation.
 ddjougan@gmail.com

This article first appeared on The Haitian Times and is republished here under a Creative Commons license.


text:
Mathieu van der Poel crashes out of Olympics after error with missing ramp
The Dutchman fell heavily on the first lap and wasn't able to rejoin the front.
by Matt de Neef
Mathieu van der Poel's hopes of Olympic gold were dashed Monday afternoon when the Dutch multi-discipline star crashed on the first of seven laps in the men's cross country mountain bike race.
Van der Poel misjudged the landing off the Sakura Drop feature on the opening lap, flew over his handlebars, and landed hard on his back and side. Eventual race winner Tom Pidcock (Great Britain) narrowly avoided colliding with Van der Poel, but managed to stay upright.
Van der Poel spent roughly a minute on the ground, nursing his ribs, before gingerly getting back on his bike and continuing on for most of the next hour. The 26-year-old eventually withdrew from the race on lap 5 and was then seen sitting on the grass in the pits area, in obvious pain.
The section Van der Poel crashed on included a ramp during training but that ramp was removed for the race. Dutch media AD Sportwereld reports that Van der Poel told his teammate Milan Vader that he thought the ramp was still there.
"It might be harsh to say but we talked about this during lunch," Vader told NOS. Mathieu said: 'Gosh everyone is jumping there.' I asked: 'won't you?' And he said 'no, I will roll down that ramp'. I said then that at the 2019 test event they had removed the ramp during the race."
Van der Poel said on Twitter: "I didn't know. The ramp was there during the recon. I only heard they removed it during the test event"
He also tweeted to Dutch journalist Thijs Zonneveld when asked if he was told before the race, "I knew nothing. Now in hospital for pictures. Too much pain to finish."
A post shared by Veloclub Argentina (@veloclub_argentina)
Van der Poel has gone to the hospital to undergo examination on his injured hip. He later posted on Instagram:
Van der Poel came into the Tokyo MTB race as one of the favourites for gold, alongside fellow multi-discipline star Pidcock. Less than a month ago Van der Poel was leading the Tour de France after a swashbuckling stage win on stage 2. Earlier this year he won his fourth cyclocross world title.
#2020 Tokyo Olympics
#Mathieu van der Poel
#mountain bike
Cyclocross World Championships Slideshow: A Tale of Rivalries, Old and New
3 days ago Read More
Mathieu van der Poel's assault convictions have been overturned
Gravel World Champs: Tech, bikes, and beer
Today's stars expect to flame out
Mathieu van der Poel was bored in his hotel room, so decided to go to a bike race
Vermeersch wins, Oss and Van der Poel complete podium at Gravel World Championships


camshaft grinding machine . Engine spare parts for MAZDA FE camshaft ... High Performance Car Exhaust Camshaft ZL01 12 440 For MAZDA.
Camshaft Vvt Gear, Wholesale Various High Quality Camshaft Vvt Gear Products from Global Camshaft Vvt Gear Suppliers and . camshaft grinding machine.
. of Toyota Camshaft Products at engine camshaft ,camshaft grinding machine . Good quality intake and exhaust camshaft for TOYOTA LAND CRUISER.
camshaft grinding machine · custom camshaft .. ISX15 QSX15 Diesel engine parts Camshaft for air intake and exhaust 4059331 4059332 4101432 3682142.
Wave125 Camshaft, Wholesale Various High Quality Wave125 Camshaft . camshaft grinding machine .. Motorcycle exhaust camshaft WAVE125 camshaft.
Camshaft 4059331, Camshaft 4059331 Suppliers and .
camshaft grinding machine .. Engine ISX15 QSX15 X15 Camshaft For Air Intake Exhaust 4059331 3680779 . Xiamen Donna Construction Machinery Co., Ltd.
S6k Camshaft, S6k Camshaft Suppliers and Manufacturers at .
S6k Camshaft, Wholesale Various High Quality S6k Camshaft Products from Global S6k Camshaft Suppliers and S6k Camshaft . camshaft grinding machine.
Mitsubishi Camshaft Md050140 Wholesale, Mitsubishi Suppliers .
Ball Mill Manufacturers offers 45 mitsubishi camshaft md050140 products. . camshaft grinding machine .. Brand New MITSUBISHI 4D56 Exhaust Camshaft MD050140.
Camshaft Assy, Camshaft Assy Suppliers and Manufacturers at .
Camshaft Assy, Wholesale Various High Quality Camshaft Assy Products from Global Camshaft Assy Suppliers and Camshaft Assy Factory,Importer . camshaft grinding machine . Jining Shante Songzheng Construction Machinery Co., Ltd. ... Car Camshaft prices Exhaust Camshaft Assy 474Q 12 440 For Haiama.
Automatic rubber EVA shoe sole making roughing grinding machine . Widened Single Side Roughing Machine With Dust Exhaust (Safety shoes) QF 519.
camshaft grinding machine · custom camshaft .. 2710503347 Camshaft Exhaust and Intake Adjuster For Mercedes C250 W211 5. Add to Compare. Response.
Variable Camshaft, Variable Camshaft Suppliers and Manufacturers .
camshaft grinding machine . 2013 2015 Dodge Dart 2.0L L4 Exhaust Variable Valve Timing Solenoid VVT Camshaft Timing. Add to Compare. Response Rate:.
camshaft grinding machine · 1hz camshaft ... engine timing exhaust camshaft adjuster actuator camshaft gear for toyota 13520 0Q010. Add to Compare.
Motorcycle Racing Camshaft Wholesale, Motorcycle Racing .
Taizhou Sanding Electric & Machinery Co., Ltd. . ISX15 QSX15 Diesel engine parts Camshaft for air intake and exhaust 4059331 4059332 4101432 3682142.
Camshaft V6, Camshaft V6 Suppliers and Manufacturers at Ball Mill Manufacturers .
camshaft grinding machine ... Rover 75 2.5 V6 Engine exhaust Camshaft LGC000270 . Exhaust Camshaft Adjuster For VW Audi 022109088M.
engine camshaft · camshaft grinding machine · custom camshaft .. Wholesale Car Parts Exhaust Camshaft for Mazda 3 1600cc Z622 12 420. Add to Compare.
China Camshaft, China Camshaft Suppliers and Manufacturers Directory . at engine camshaft ,camshaft grinding machine ,custom camshaft from China Ball Mill Manufacturers . . OE#06H109022BB Exhaust Camshaft 1.8 TSI TFSI 06H109571K.
Industrial Flour Mill Commercial Nut Grinder Machine , Find Complete Details about Industrial Flour Mill Commercial Nut . 3 side heat exhaust for better working.
. of Camshaft Products at engine camshaft ,camshaft grinding machine ,1hz camshaft . EXHAUST CAMSHAFT For Mitsubishi Triton L200 KA4T KB4T 4D56.
Camshaft For 2tr, Camshaft For 2tr Suppliers and Manufacturers at .
camshaft grinding machine . Add to Favorites. Alloy chilled casting camshaft prices for 2TR intake & exhaust .. Xiamen Donna Construction Machinery Co., Ltd.
Ball Mill Manufacturers offers 14 4g64 performance camshaft products. . camshaft grinding machine . Shenyang R&Z Power Machinery And Equipment Co., Ltd.
camshaft grinding machine .. Bochi Machinery Corporation (Chongqing) . engine parts repair kit with camshaft intake exhaust valves for CHANA SUKUKI.


We are a network of freelance British Sign Language/English interpreters, living and working across Essex, London and the South East. All members of the group are registered with the regulating body NRCPD (National Registers of Communication Professionals working with Deaf and Deafblind People). We are all committed to professional standards and excellent customer care in our work.
You can use this website to find and book a Registered BSL Interpreter direct:, reducing your time and costs. You will also find additional information about interpreting, the Deaf community and many other useful resources.
Read profiles of BSL interpreters who live and work in Essex. Send booking requests direct to the interpreter of your choice. By making a booking direct you will not incur any agency charges, this site is free to use.
Our resources section provides latest news, information about interpreters, how to work with them and what their qualifications and levels of registration mean. You will also find frequently asked questions to guide you in booking an interpreter.


text:
import cuid from 'cuid'
import { Immutable } from 'immer'
import { Counter, exponentialBuckets, Histogram } from 'prom-client'
import { container, inject, singleton } from 'tsyringe'
import { ReadonlyDeep } from 'type-fest'
import { assertUnreachable } from '../../../common/assert-unreachable'
import { isAbortError, raceAbort } from '../../../common/async/abort-signals'
import CancelToken from '../../../common/async/cancel-token'
import createDeferred, { Deferred } from '../../../common/async/deferred'
import swallowNonBuiltins from '../../../common/async/swallow-non-builtins'
import { timeoutPromise } from '../../../common/async/timeout-promise'
import { subtract } from '../../../common/data-structures/sets'
import { GameRoute } from '../../../common/game-launch-config'
import {
  GameConfig,
  GameConfigPlayer,
  GameSource,
  GameType,
  MatchmakingExtra,
} from '../../../common/games/configuration'
import { createHuman, Slot } from '../../../common/lobbies/slot'
import { MapInfo, MapInfoJson, toMapInfoJson } from '../../../common/maps'
import {
  ALL_MATCHMAKING_TYPES,
  MatchmakingCompletionType,
  MatchmakingEvent,
  MatchmakingPreferences,
  MatchmakingSeason,
  MatchmakingServiceErrorCode,
  MatchmakingType,
  MATCHMAKING_ACCEPT_MATCH_TIME_MS,
  PreferenceData,
  TEAM_SIZES,
} from '../../../common/matchmaking'
import { BwTurnRate, BwUserLatency } from '../../../common/network'
import { RaceChar } from '../../../common/races'
import { randomInt, randomItem } from '../../../common/random'
import { urlPath } from '../../../common/urls'
import { SbUserId } from '../../../common/users/sb-user'
import { GameLoader, GameLoaderError } from '../games/game-loader'
import { GameplayActivityRegistry } from '../games/gameplay-activity-registry'
import logger from '../logging/logger'
import { getMapInfo } from '../maps/map-models'
import {
  calcEffectiveRating,
  Matchmaker,
  MATCHMAKING_INTERVAL_MS,
  OnMatchFoundFunc,
} from '../matchmaking/matchmaker'
import {
  getMatchmakingEntityId,
  getNumPlayersInEntity,
  getPlayersFromEntity,
  MatchmakingEntity,
  MatchmakingParty,
  MatchmakingPlayer,
  MatchmakingPlayerData,
  matchmakingRatingToPlayerData,
} from '../matchmaking/matchmaking-entity'
import MatchmakingStatusService from '../matchmaking/matchmaking-status'
import {
  createInitialMatchmakingRating,
  getMatchmakingRating,
  insertMatchmakingCompletion,
  MatchmakingRating,
} from '../matchmaking/models'
import { getCurrentMapPool } from '../models/matchmaking-map-pools'
import { InPartyChecker, IN_PARTY_CHECKER } from '../parties/in-party-checker'
import { Clock } from '../time/clock'
import { ClientIdentifierString } from '../users/client-ids'
import { UserIdentifierManager } from '../users/user-identifier-manager'
import { findUsersByIdAsMap } from '../users/user-model'
import {
  ClientSocketsGroup,
  ClientSocketsManager,
  UserSocketsGroup,
  UserSocketsManager,
} from '../websockets/socket-groups'
import { TypedPublisher } from '../websockets/typed-publisher'
import { MatchmakingSeasonsService } from './matchmaking-seasons'
import { MatchmakingServiceError } from './matchmaking-service-error'
import { adjustMatchmakingRatingForInactivity } from './rating'

interface MatchmakerCallbacks {
  onMatchFound: OnMatchFoundFunc
}

interface GameLoaderCallbacks {
  onGameSetup: (props: {
    matchInfo: Readonly<Match>
    clients: ReadonlyArray<ClientSocketsGroup>
    slots: ReadonlyArray<Slot>
    setup: {
      gameId: string
      seed: number
      turnRate?: BwTurnRate
      userLatency?: BwUserLatency
    }
    resultCodes: ReadonlyMap<SbUserId, string>
    chosenMap: MapInfoJson
    cancelToken: CancelToken
  }) => void
  onRoutesSet: (
    clients: ReadonlyArray<ClientSocketsGroup>,
    playerName: string,
    routes: ReadonlyArray<GameRoute>,
    gameId: string,
  ) => void
  onGameLoaded: (clients: ReadonlyArray<ClientSocketsGroup>) => void
}

class Match {
  private acceptPromises = new Map<SbUserId, Deferred<void>>()
  private acceptTimeout: Promise<void>
  private clearAcceptTimeout: (reason?: any) => void
  private userIdToRegisteredId = new Map<SbUserId, SbUserId>()

  private toKick = new Set<SbUserId>()
  private abortController = new AbortController()

  constructor(
    readonly id: string,
    readonly type: MatchmakingType,
    readonly teams: Immutable<MatchmakingEntity[][]>,
  ) {
    for (const entities of teams) {
      for (const entity of entities) {
        const registeredId = getMatchmakingEntityId(entity)
        for (const p of getPlayersFromEntity(entity)) {
          this.acceptPromises.set(p.id, createDeferred())
          this.userIdToRegisteredId.set(p.id, registeredId)
        }
      }
    }

    ;[this.acceptTimeout, this.clearAcceptTimeout] = timeoutPromise(
      MATCHMAKING_ACCEPT_MATCH_TIME_MS + ACCEPT_MATCH_LATENCY,
    )

    this.acceptTimeout
      .then(() => {
        if (this.acceptPromises.size) {
          for (const id of this.acceptPromises.keys()) {
            this.toKick.add(this.userIdToRegisteredId.get(id)!)
          }
          this.abortController.abort()
        }
      })
      .catch(swallowNonBuiltins)
  }

  get acceptStateChanged(): Promise<void> {
    return raceAbort(
      this.abortController.signal,
      Promise.race(Array.from(this.acceptPromises.values())),
    )
  }

  *players(): Generator<Immutable<MatchmakingPlayerData>> {
    for (const entities of this.teams) {
      for (const entity of entities) {
        for (const p of getPlayersFromEntity(entity)) {
          yield p
        }
      }
    }
  }

  get totalPlayers(): number {
    return TEAM_SIZES[this.type] * this.teams.length
  }

  get numAccepted(): number {
    return this.totalPlayers - this.acceptPromises.size
  }

  registerAccept(userId: SbUserId) {
    this.acceptPromises.get(userId)?.resolve()
    this.acceptPromises.delete(userId)

    if (this.numAccepted === this.totalPlayers) {
      this.clearAcceptTimeout()
    }
  }

  registerDecline(userId: SbUserId) {
    this.toKick.add(this.userIdToRegisteredId.get(userId)!)
    this.abortController.abort()
    this.clearAcceptTimeout()
  }

  getKicksAndRequeues(): [toKick: Set<SbUserId>, toRequeue: Set<SbUserId>] {
    // Requeue any party leaders and solo players that don't appear in toKick
    const toRequeue = subtract(
      new Set(this.teams.flatMap(team => team.map(entity => getMatchmakingEntityId(entity)))),
      this.toKick,
    )
    return [new Set(this.toKick), toRequeue]
  }
}

interface QueueEntry {
  userId: SbUserId
  /** The user ID that the matchmaking queue is registered under. (e.g. the party leader's ID) */
  registeredId: SbUserId
  type: MatchmakingType
  partyId?: string
  matchId?: string
}

interface Timers {
  mapSelectionTimer?: Deferred<void>
  countdownTimer?: Deferred<void>
  cancelToken: CancelToken
}

// Extra time that is added to the matchmaking accept time to account for latency in getting
// messages back and forth from clients
const ACCEPT_MATCH_LATENCY = 2000

/**
 * Selects a map for the given players and matchmaking type, based on the players' stored
 * matchmaking preferences and the current map pool.
 */
async function pickMap(
  matchmakingType: MatchmakingType,
  entities: Immutable<MatchmakingEntity[]>,
): Promise<MapInfo> {
  const currentMapPool = await getCurrentMapPool(matchmakingType)
  if (!currentMapPool) {
    throw new MatchmakingServiceError(
      MatchmakingServiceErrorCode.InvalidMapPool,
      "Map pool doesn't exist",
    )
  }

  // TODO(tec27): Handle parties in 2v2: only the leader's selections should be used

  // The algorithm for selecting maps is:
  // 1) All players' map selections are treated as vetoes, and removed from the available map pool.
  //    We also track how many times each map was vetoed.
  // 2a) If any maps are remaining, select a random map from the remaining ones
  // 2b) If no maps are remaining, select a random map from the least vetoed maps

  const fullMapPool = new Set(currentMapPool.maps)
  const vetoCount = new Map<string, number>()
  let mapPool = fullMapPool
  for (const e of entities) {
    let mapSelections: ReadonlyArray<string>
    if ('players' in e) {
      mapSelections = e.players.find(p => p.id === e.leaderId)!.mapSelections
    } else {
      mapSelections = e.mapSelections
    }
    mapPool = subtract(mapPool, mapSelections)
    for (const map of mapSelections) {
      vetoCount.set(map, (vetoCount.get(map) ?? 0) + 1)
    }
  }

  if (!mapPool.size) {
    // All available maps were vetoed, create a final pool from the least vetoed maps
    // NOTE(tec27): We know since the whole pool was vetoed, each map in the pool will have an entry
    // here, even though we didn't initialize the Map directly
    const sortedByVetoCount = Array.from(vetoCount.entries()).sort((a, b) => a[1] - b[1])
    const leastVetoCount = sortedByVetoCount[0][1]
    let lastElem = 1
    while (
      lastElem < sortedByVetoCount.length &&
      sortedByVetoCount[lastElem][1] <= leastVetoCount
    ) {
      lastElem += 1
    }

    mapPool = new Set(sortedByVetoCount.slice(0, lastElem).map(e => e[0]))
  }

  const chosenMapId = randomItem(Array.from(mapPool))
  const mapInfo = await getMapInfo([chosenMapId])

  if (!mapInfo.length) {
    throw new MatchmakingServiceError(
      MatchmakingServiceErrorCode.InvalidMaps,
      'Some (or all) of the maps not found',
    )
  }

  const chosenMap = mapInfo[0]

  return chosenMap
}

@singleton()
export class MatchmakingService {
  private matchmakerDelegate: MatchmakerCallbacks = {
    onMatchFound: (teamA, teamB) => {
      const playerEntry = this.queueEntries.get(getMatchmakingEntityId(teamA[0]))!

      const matchInfo = new Match(cuid(), playerEntry.type, [teamA, teamB])
      this.matches.set(matchInfo.id, matchInfo)

      for (const entities of [teamA, teamB]) {
        for (const entity of entities) {
          for (const p of getPlayersFromEntity(entity)) {
            const queueEntry = this.queueEntries.get(p.id)!
            queueEntry.matchId = matchInfo.id
            this.publishToActiveClient(p.id, {
              type: 'matchFound',
              matchmakingType: matchInfo.type,
              numPlayers: matchInfo.totalPlayers,
            })
          }
        }
      }

      const completionTime = new Date(this.clock.now())
      for (const entities of [teamA, teamB]) {
        for (const entity of entities) {
          for (const p of getPlayersFromEntity(entity)) {
            insertMatchmakingCompletion({
              userId: p.id,
              matchmakingType: matchInfo.type,
              completionType: MatchmakingCompletionType.Found,
              searchTimeMillis: entity.searchIterations * MATCHMAKING_INTERVAL_MS,
              completionTime,
            }).catch(err => logger.error({ err }, 'error while logging matchmaking completion'))
          }
          this.matchSearchTimeMetric
            .labels(
              matchInfo.type,
              String(getNumPlayersInEntity(entity)),
              MatchmakingCompletionType.Found,
            )
            .observe((entity.searchIterations * MATCHMAKING_INTERVAL_MS) / 1000)
        }
      }

      this.matchesFoundMetric.labels(matchInfo.type).inc()

      this.runMatch(matchInfo.id).catch(swallowNonBuiltins)
    },
  }

  private gameLoaderDelegate: GameLoaderCallbacks = {
    onGameSetup: async ({
      matchInfo,
      clients,
      slots,
      setup,
      resultCodes,
      chosenMap,
      cancelToken,
    }) => {
      cancelToken.throwIfCancelling()

      const playersJson = matchInfo.teams.flatMap(team =>
        team.flatMap(entities =>
          Array.from(getPlayersFromEntity(entities), p => {
            const slot = slots.find(s => s.name === p.name)!

            return {
              id: p.id,
              name: p.name,
              race: slot.race,
              rating: p.rating,
            }
          }),
        ),
      )

      // Using `map` with `Promise.all` here instead of `forEach`, so our general error handler
      // catches any of the errors inside.
      await Promise.all(
        clients.map(async client => {
          let published = this.publishToActiveClient(client.userId, {
            type: 'matchReady',
            matchmakingType: matchInfo.type,
            setup,
            resultCode: resultCodes.get(client.userId),
            slots,
            players: playersJson,
            chosenMap,
          })

          if (!published) {
            throw new MatchmakingServiceError(
              MatchmakingServiceErrorCode.ClientDisconnected,
              `Match cancelled, ${client.name} disconnected`,
            )
          }

          let mapSelectionTimerId
          let countdownTimerId
          try {
            const mapSelectionTimer = createDeferred<void>()
            mapSelectionTimer.catch(swallowNonBuiltins)
            let timers = this.clientTimers.get(client.userId) ?? { cancelToken }
            timers.mapSelectionTimer = mapSelectionTimer
            this.clientTimers.set(client.userId, timers)
            mapSelectionTimerId = setTimeout(() => mapSelectionTimer.resolve(), 5000)
            await mapSelectionTimer
            cancelToken.throwIfCancelling()

            published = this.publishToActiveClient(client.userId, { type: 'startCountdown' })
            if (!published) {
              throw new MatchmakingServiceError(
                MatchmakingServiceErrorCode.ClientDisconnected,
                `Match cancelled, ${client.name} disconnected`,
              )
            }

            const countdownTimer = createDeferred<void>()
            countdownTimer.catch(swallowNonBuiltins)
            timers = this.clientTimers.get(client.userId) ?? { cancelToken }
            timers.countdownTimer = countdownTimer
            this.clientTimers.set(client.userId, timers)
            countdownTimerId = setTimeout(() => countdownTimer.resolve(), 5000)

            await countdownTimer
            cancelToken.throwIfCancelling()

            published = this.publishToActiveClient(client.userId, {
              type: 'startWhenReady',
              gameId: setup.gameId!,
            })
            if (!published) {
              throw new MatchmakingServiceError(
                MatchmakingServiceErrorCode.ClientDisconnected,
                `Match cancelled, ${client.name} disconnected`,
              )
            }
          } finally {
            if (mapSelectionTimerId) {
              clearTimeout(mapSelectionTimerId)
              mapSelectionTimerId = null
            }
            if (countdownTimerId) {
              clearTimeout(countdownTimerId)
              countdownTimerId = null
            }
          }
        }),
      )
    },

    onRoutesSet: (clients, playerName, routes, gameId) => {
      for (const c of clients) {
        // TODO(tec27): It'd be a lot nicer if this just delivered us the user ID of this player
        // instead of their name (or gave both).
        if (c.name === playerName) {
          this.publishToActiveClient(c.userId, {
            type: 'setRoutes',
            routes,
            gameId,
          })
          break
        }
      }
    },

    onGameLoaded: clients => {
      for (const client of clients) {
        this.publishToActiveClient(client.userId, { type: 'gameStarted' })
        // TODO(tec27): Should this be maintained until the client reports game exit instead?
        this.unregisterActivity(client.userId)
      }
    },
  }

  private matchmakers: Map<MatchmakingType, Matchmaker>
  // Maps user ID -> QueueEntry
  private queueEntries = new Map<SbUserId, QueueEntry>()
  // Maps match ID -> Match
  private matches = new Map<string, Match>()
  // Maps user ID -> Timers
  private clientTimers = new Map<SbUserId, Timers>()

  private matchesRequestedMetric = new Counter({
    name: 'shieldbattery_matchmaker_matches_requested_total',
    labelNames: ['matchmaking_type', 'party_size'],
    help: 'Total number of matches requested',
  })
  private matchesFoundMetric = new Counter({
    name: 'shieldbattery_matchmaker_matches_found_total',
    labelNames: ['matchmaking_type'],
    help: 'Total number of matches found',
  })
  private matchRequestsCanceledMetric = new Counter({
    name: 'shieldbattery_matchmaker_match_requests_canceled_total',
    labelNames: ['matchmaking_type', 'party_size'],
    help: 'Total number of cancellations of match requests',
  })
  private matchSearchTimeMetric = new Histogram({
    name: 'shieldbattery_matchmaker_completion_seconds',
    labelNames: ['matchmaking_type', 'party_size', 'completion_type'],
    help: 'Duration of a matchmaking search in seconds',
    buckets: exponentialBuckets(1, 1.75, 20),
  })

  // Injecting this many is fine!
  // eslint-disable-next-line max-params
  constructor(
    private publisher: TypedPublisher<ReadonlyDeep<MatchmakingEvent>>,
    private userSocketsManager: UserSocketsManager,
    private clientSocketsManager: ClientSocketsManager,
    private matchmakingStatus: MatchmakingStatusService,
    private activityRegistry: GameplayActivityRegistry,
    private gameLoader: GameLoader,
    @inject(IN_PARTY_CHECKER) private inPartyChecker: InPartyChecker,
    private matchmakingSeasonsService: MatchmakingSeasonsService,
    private clock: Clock,
    private userIdentifierManager: UserIdentifierManager,
  ) {
    this.matchmakers = new Map(
      ALL_MATCHMAKING_TYPES.map(type => [
        type,
        container
          .resolve(Matchmaker)
          .setMatchmakingType(type)
          .setOnMatchFound(this.matchmakerDelegate.onMatchFound),
      ]),
    )
  }

  /**
   * Adds a user to the matchmaking queue. This can only be used for solo players, players in a
   * party should use `findAsParty` instead (this call will fail for them).
   */
  async find(
    userId: SbUserId,
    clientId: string,
    identifiers: ReadonlyArray<ClientIdentifierString>,
    preferences: MatchmakingPreferences,
  ): Promise<void> {
    const { matchmakingType: type } = preferences
    const clientSockets = this.getClientSocketsOrFail(userId, clientId)

    if (!this.matchmakingStatus.isEnabled(type)) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.MatchmakingDisabled,
        'Matchmaking is currently disabled',
      )
    }

    if (this.inPartyChecker.isInParty(userId)) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.InParty,
        'User is in a party, cannot queue as solo player',
      )
    }

    if (!this.activityRegistry.registerActiveClient(userId, clientSockets)) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.GameplayConflict,
        'User is already active in a gameplay activity',
      )
    }

    try {
      await this.queueSoloPlayer(userId, clientId, identifiers, preferences)
    } catch (err) {
      // Clear out the activity registry for this user, since they didn't actually make it into the
      // queue
      this.activityRegistry.unregisterClientForUser(userId)
      throw err
    }
  }

  /**
   * Helper that deals with actually putting a solo player into the queue. This should only be
   * called *after* the player's request has been validated and they've been registered as the
   * active gameplay client.
   */
  private async queueSoloPlayer(
    userId: SbUserId,
    clientId: string,
    identifiers: ReadonlyArray<ClientIdentifierString>,
    preferences: Pick<
      MatchmakingPreferences,
      'matchmakingType' | 'race' | 'mapSelections' | 'data'
    >,
  ): Promise<void> {
    const { matchmakingType: type, race, mapSelections, data: preferenceData } = preferences
    const userSockets = this.getUserSocketsOrFail(userId)
    const clientSockets = this.getClientSocketsOrFail(userId, clientId)

    const season = await this.matchmakingSeasonsService.getCurrentSeason()
    const mmr = await this.retrieveMmr(userId, type, season, identifiers)
    const playerData = matchmakingRatingToPlayerData({
      mmr,
      username: userSockets.name,
      race,
      mapSelections: mapSelections.slice(),
      preferenceData,
    })

    // TODO(tec27): Bump up the uncertainty based on how long ago the last played date was:
    // "After [14] days, the inactive player's uncertainty (search range) increases by 24 per day,
    // up to a maximum of 336 after 14 additional days."
    const halfUncertainty = mmr.uncertainty / 2

    const player: MatchmakingPlayer = {
      ...playerData,
      interval: {
        low: mmr.rating - halfUncertainty,
        high: mmr.rating + halfUncertainty,
      },
      searchIterations: 0,
    }

    this.matchmakers.get(type)!.addToQueue(player)
    this.queueEntries.set(userId, {
      type,
      userId,
      registeredId: userId,
    })

    this.subscribeUserToQueueUpdates(userSockets, clientSockets, type, race)
    this.matchesRequestedMetric.labels(type, '1').inc()
  }

  /**
   * Adds a party of users to matchmaking as a single group. Callers of this need to manage the
   * registering the gameplay activity status themselves (until this function returns successfully,
   * at which point the activity status will be handled by this service).
   */
  async findAsParty({
    type,
    users,
    partyId,
    leaderId,
    leaderPreferences,
  }: {
    type: MatchmakingType
    users: Readonly<
      Map<
        SbUserId,
        { race: RaceChar; clientId: string; identifiers: ReadonlyArray<ClientIdentifierString> }
      >
    >
    partyId: string
    leaderId: SbUserId
    leaderPreferences: {
      mapSelections: ReadonlyArray<string>
      preferenceData: Readonly<PreferenceData>
    }
  }): Promise<void> {
    const { mapSelections, preferenceData } = leaderPreferences

    if (!this.matchmakingStatus.isEnabled(type)) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.MatchmakingDisabled,
        'Matchmaking is currently disabled',
      )
    }

    if (users.size > TEAM_SIZES[type]) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.TooManyPlayers,
        'Party is too large for that matchmaking type',
      )
    }

    const anyNotInGameplay = Array.from(users.entries()).some(
      ([id, { clientId }]) => this.activityRegistry.getClientForUser(id)?.clientId !== clientId,
    )
    if (anyNotInGameplay) {
      // This is a programming error, rather than something the user should really ever encounter
      throw new Error('At least one party user was not registered in gameplay activity')
    }

    if (users.size === 1) {
      // Just queue as a solo player to simplify the matchmaker logic (which assumes a party is
      // 2 players)
      const user = users.get(leaderId)!
      await this.queueSoloPlayer(leaderId, user.clientId, user.identifiers, {
        matchmakingType: type,
        race: user.race,
        mapSelections: mapSelections.slice(),
        data: preferenceData,
      })
      return
    }

    const season = await this.matchmakingSeasonsService.getCurrentSeason()
    const names = await findUsersByIdAsMap(Array.from(users.keys()))
    const mmrs = await Promise.all(
      Array.from(users.entries(), ([id, { identifiers }]) =>
        this.retrieveMmr(id, type, season, identifiers),
      ),
    )
    const matchmakingParty: MatchmakingParty = {
      leaderId,
      partyId,
      players: mmrs.map(mmr =>
        matchmakingRatingToPlayerData({
          mmr,
          username: names.get(mmr.userId)!.name,
          race: users.get(mmr.userId)!.race,
          mapSelections: mapSelections.slice(),
          preferenceData,
        }),
      ),
      // We'll update this below
      interval: {
        low: 0,
        high: 0,
      },
      searchIterations: 0,
    }

    const effectiveRating = calcEffectiveRating([matchmakingParty])
    // Choose the largest uncertainty among the party members to use as the party's uncertainty
    let uncertainty = 0
    for (const mmr of mmrs) {
      if (mmr.uncertainty > uncertainty) {
        uncertainty = mmr.uncertainty
      }
    }

    const halfUncertainty = uncertainty / 2
    matchmakingParty.interval.low = effectiveRating - halfUncertainty
    matchmakingParty.interval.high = effectiveRating + halfUncertainty

    const userToSockets = new Map(
      Array.from(users.entries(), ([id, { clientId }]) => [
        id,
        {
          userSockets: this.getUserSocketsOrFail(id),
          clientSockets: this.getClientSocketsOrFail(id, clientId),
        },
      ]),
    )

    this.matchmakers.get(type)!.addToQueue(matchmakingParty)
    for (const [userId, { userSockets, clientSockets }] of userToSockets.entries()) {
      this.queueEntries.set(userId, {
        type,
        userId,
        registeredId: leaderId,
        partyId,
      })
      this.subscribeUserToQueueUpdates(userSockets, clientSockets, type, users.get(userId)!.race)
    }
    this.matchesRequestedMetric.labels(type, String(users.size)).inc()
  }

  async cancel(userId: SbUserId): Promise<void> {
    const clientSockets = this.activityRegistry.getClientForUser(userId)
    if (!clientSockets || !this.queueEntries.has(userId)) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.NotInQueue,
        'User does not have an active matchmaking queue',
      )
    } else if (this.queueEntries.get(userId)?.matchId) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.MatchAlreadyStarting,
        'Match is already starting and cannot be canceled',
      )
    }

    this.removeClientFromMatchmaking(clientSockets, false)
  }

  async accept(userId: SbUserId): Promise<void> {
    const queueEntry = this.queueEntries.get(userId)
    if (!queueEntry?.matchId) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.NoActiveMatch,
        'No active match found',
      )
    }

    this.matches.get(queueEntry.matchId)?.registerAccept(userId)
  }

  /**
   * Register that a player left a party. If that party is currently queued, we treat this like a
   * disconnect.
   */
  registerPartyLeave(userId: SbUserId, partyId: string): void {
    const queueEntry = this.queueEntries.get(userId)
    if (queueEntry?.partyId === partyId) {
      this.removeClientFromMatchmaking(this.activityRegistry.getClientForUser(userId)!, false)
    }
  }

  private async retrieveMmr(
    userId: SbUserId,
    type: MatchmakingType,
    season: MatchmakingSeason,
    identifiers: ReadonlyArray<ClientIdentifierString>,
  ): Promise<MatchmakingRating> {
    let currentMmr = await getMatchmakingRating(userId, type, season.id)
    if (!currentMmr) {
      const sameUsers = await this.userIdentifierManager.findUsersWithIdentifiers(identifiers)
      currentMmr = await createInitialMatchmakingRating(userId, type, season, sameUsers)
    }

    return adjustMatchmakingRatingForInactivity(currentMmr, new Date(this.clock.now()))
  }

  private subscribeUserToQueueUpdates(
    userSockets: UserSocketsGroup,
    clientSockets: ClientSocketsGroup,
    matchmakingType: MatchmakingType,
    race: RaceChar,
  ): void {
    userSockets.subscribe<MatchmakingEvent>(
      MatchmakingService.getUserPath(userSockets.userId),
      () => {
        return {
          type: 'queueStatus',
          matchmaking: { type: matchmakingType },
        }
      },
    )
    clientSockets.subscribe<MatchmakingEvent>(
      MatchmakingService.getClientPath(clientSockets),
      () => ({
        type: 'startSearch',
        matchmakingType,
        race,
      }),
      sockets => this.removeClientFromMatchmaking(sockets, true),
    )
  }

  private async runMatch(matchId: string) {
    const match = this.matches.get(matchId)!
    let phase: 'accepting' | 'loading' = 'accepting'

    try {
      while (match.numAccepted < match.totalPlayers) {
        await match.acceptStateChanged

        for (const p of match.players()) {
          this.publishToActiveClient(p.id, {
            type: 'playerAccepted',
            acceptedPlayers: match.numAccepted,
          })
        }
      }

      phase = 'loading'
      await this.doGameLoad(match)
    } catch (err: any) {
      if (
        !isAbortError(err) &&
        !(err instanceof MatchmakingServiceError) &&
        !(err instanceof GameLoaderError)
      ) {
        logger.error({ err }, 'error while processing match')
      }

      // TODO(tec27): GameLoaderError can tell us what player failed to load now, we should add that
      // player (or their party) to the kick list

      const [toKick, toRequeue] = match.getKicksAndRequeues()

      const entities = match.teams.flat()

      for (const id of toKick) {
        const entity = entities.find(entity => getMatchmakingEntityId(entity) === id)!
        for (const p of getPlayersFromEntity(entity)) {
          this.queueEntries.delete(p.id)
          this.publishToActiveClient(p.id, {
            type: 'acceptTimeout',
          })
          this.unregisterActivity(p.id)
        }
      }

      for (const id of toRequeue) {
        const entity = entities.find(entity => getMatchmakingEntityId(entity) === id)!
        let playerMissing = false

        for (const p of getPlayersFromEntity(entity)) {
          const queueEntry = this.queueEntries.get(p.id)
          if (!queueEntry) {
            // This client must have disconnected/left
            playerMissing = true
            continue
          }

          queueEntry.matchId = undefined

          if (phase === 'loading') {
            // TODO(tec27): Give a better reason here, and ideally derive who to kick from the load
            // failures
            this.publishToActiveClient(p.id, {
              type: 'cancelLoading',
              reason: 'loading failed',
            })
          }

          this.publishToActiveClient(p.id, {
            type: 'requeue',
          })
        }

        if (!playerMissing) {
          // Generate a writable version of the MatchmakingEntity
          const newQueueEntry =
            'players' in entity
              ? {
                  ...entity,
                  players: entity.players.map(p => ({ ...p })),
                }
              : {
                  ...entity,
                }

          this.matchmakers.get(match.type)!.addToQueue(newQueueEntry)
        }
      }
    } finally {
      this.matches.delete(match.id)
    }
  }

  private async doGameLoad(match: Match) {
    let slots: Slot[]
    let teams: GameConfigPlayer[][]
    const players = Array.from(match.players())
    const clients: ClientSocketsGroup[] = []
    let declined = false
    for (const p of players) {
      const client = this.activityRegistry.getClientForUser(p.id)
      if (!client) {
        match.registerDecline(p.id)
        declined = true
      } else {
        clients.push(client)
      }
    }
    if (declined) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.ClientDisconnected,
        'client disconnected before game load',
      )
    }

    if (match.type === MatchmakingType.Match1v1) {
      // NOTE(tec27): Type inference here is kinda bad, it should know that Match is a Match1v1 at
      // this point and thus preferenceData has certain things, but it doesn't =/

      const firstPlayer = players[0]
      // NOTE(tec27): alternate race selection is not available for random users. We block this
      // from being set elsewhere, but ignore it here just in case
      const playersHaveSameRace =
        firstPlayer.race !== 'r' && players.every(p => p.race === firstPlayer.race)
      if (playersHaveSameRace && players.every(p => p.preferenceData.useAlternateRace === true)) {
        // All players have the same race and all of them want to use an alternate race: select
        // one of the players randomly to play their alternate race, leaving the other player to
        // play their main race.
        const randomPlayerIndex = randomInt(0, players.length)
        slots = players.map((p, i) =>
          createHuman(
            p.name,
            p.id,
            i === randomPlayerIndex ? p.preferenceData.alternateRace : p.race,
          ),
        )
      } else if (
        playersHaveSameRace &&
        players.some(p => p.preferenceData.useAlternateRace === true)
      ) {
        // All players have the same main race and one of them wants to use an alternate race
        slots = players.map(p =>
          createHuman(
            p.name,
            p.id,
            p.preferenceData.useAlternateRace ? p.preferenceData.alternateRace : p.race,
          ),
        )
      } else {
        // No alternate race selection, so everyone gets their selected race
        slots = players.map(p => createHuman(p.name, p.id, p.race))
      }

      teams = [
        slots.map(s => ({
          id: s.userId,
          race: s.race,
          isComputer: s.type === 'computer' || s.type === 'umsComputer',
        })),
      ]
    } else {
      // Alternate race is not allowed for non-1v1 matchmaking types, so this is very simple!
      const slotsInTeams = match.teams.map(team =>
        team.flatMap(entity =>
          Array.from(getPlayersFromEntity(entity), p => createHuman(p.name, p.id, p.race)),
        ),
      )
      slots = slotsInTeams.flat()
      teams = slotsInTeams.map(t =>
        t.map(s => ({
          id: s.userId,
          race: s.race,
          isComputer: s.type === 'computer' || s.type === 'umsComputer',
        })),
      )
    }

    const entities = match.teams.flat()
    const chosenMap = await pickMap(match.type, entities)

    let gameSourceExtra: MatchmakingExtra
    switch (match.type) {
      case MatchmakingType.Match1v1:
        gameSourceExtra = {
          type: match.type,
        }
        break
      case MatchmakingType.Match2v2:
        gameSourceExtra = {
          type: match.type,
          parties: entities.map(entity => Array.from(getPlayersFromEntity(entity), p => p.id)),
        }
        break
      default:
        gameSourceExtra = assertUnreachable(match.type)
    }

    const gameConfig: GameConfig = {
      gameType: match.type === MatchmakingType.Match1v1 ? GameType.OneVsOne : GameType.TopVsBottom,
      gameSubType: match.type === MatchmakingType.Match1v1 ? 0 : TEAM_SIZES[match.type],
      gameSource: GameSource.Matchmaking,
      gameSourceExtra,
      teams,
    }

    const loadCancelToken = new CancelToken()
    const gameLoaded = this.gameLoader.loadGame({
      players: slots,
      mapId: chosenMap.id,
      gameConfig,
      cancelToken: loadCancelToken,
      onGameSetup: (setup, resultCodes) =>
        this.gameLoaderDelegate.onGameSetup({
          matchInfo: match,
          clients,
          slots,
          setup,
          resultCodes,
          chosenMap: toMapInfoJson(chosenMap),
          cancelToken: loadCancelToken,
        }),
      onRoutesSet: (playerName, routes, gameId) =>
        this.gameLoaderDelegate.onRoutesSet(clients, playerName, routes, gameId),
    })

    await gameLoaded

    for (const player of match.players()) {
      this.queueEntries.delete(player.id)
    }

    this.gameLoaderDelegate.onGameLoaded(clients)
  }

  private unregisterActivity(userId: SbUserId) {
    const activeClient = this.activityRegistry.getClientForUser(userId)
    this.activityRegistry.unregisterClientForUser(userId)
    this.publishToUser(userId, {
      type: 'queueStatus',
      matchmaking: undefined,
    })

    const userSockets = this.userSocketsManager.getById(userId)
    userSockets?.unsubscribe(MatchmakingService.getUserPath(userId))
    activeClient?.unsubscribe(MatchmakingService.getClientPath(activeClient))
  }

  private removeClientFromMatchmaking(client: ClientSocketsGroup, isDisconnect = true) {
    // NOTE(2Pac): Client can leave, i.e. disconnect, during the queueing process, during the
    // loading process, or even during the game process.
    const entry = this.queueEntries.get(client.userId)
    const toUnregister = [client.userId]
    // Means the client disconnected during the queueing process
    if (entry) {
      this.queueEntries.delete(client.userId)

      const entity = this.matchmakers.get(entry.type)!.removeFromQueue(entry.registeredId)
      if (entity) {
        toUnregister.length = 0
        for (const player of getPlayersFromEntity(entity)) {
          toUnregister.push(player.id)
          this.queueEntries.delete(player.id)

          insertMatchmakingCompletion({
            userId: player.id,
            matchmakingType: entry.type,
            completionType: isDisconnect
              ? MatchmakingCompletionType.Disconnect
              : MatchmakingCompletionType.Cancel,
            searchTimeMillis: entity.searchIterations * MATCHMAKING_INTERVAL_MS,
            completionTime: new Date(this.clock.now()),
          }).catch(err => logger.error({ err }, 'error while logging matchmaking completion'))
        }

        this.matchSearchTimeMetric
          .labels(
            entry.type,
            String(getNumPlayersInEntity(entity)),
            isDisconnect ? MatchmakingCompletionType.Disconnect : MatchmakingCompletionType.Cancel,
          )
          .observe((entity.searchIterations * MATCHMAKING_INTERVAL_MS) / 1000)
        this.matchRequestsCanceledMetric
          .labels(entry.type, String(getNumPlayersInEntity(entity)))
          .inc()
      }

      if (entry.matchId) {
        this.matches.get(entry.matchId)?.registerDecline(client.userId)
      }
    }

    for (const userId of toUnregister) {
      if (this.clientTimers.has(userId)) {
        // Means the client disconnected during the loading process
        const { mapSelectionTimer, countdownTimer, cancelToken } = this.clientTimers.get(userId)!
        if (countdownTimer) {
          countdownTimer.reject(new Error('Countdown cancelled'))
        }
        if (mapSelectionTimer) {
          mapSelectionTimer.reject(new Error('Map selection cancelled'))
        }

        cancelToken.cancel()

        this.clientTimers.delete(userId)
      }

      this.unregisterActivity(userId)
    }
  }

  private getUserSocketsOrFail(userId: SbUserId): UserSocketsGroup {
    const userSockets = this.userSocketsManager.getById(userId)
    if (!userSockets) {
      throw new MatchmakingServiceError(MatchmakingServiceErrorCode.UserOffline, 'User is offline')
    }

    return userSockets
  }

  private getClientSocketsOrFail(userId: SbUserId, clientId: string): ClientSocketsGroup {
    const clientSockets = this.clientSocketsManager.getById(userId, clientId)
    if (!clientSockets) {
      throw new MatchmakingServiceError(
        MatchmakingServiceErrorCode.UserOffline,
        'Client could not be found',
      )
    }
    if (clientSockets.clientType !== 'electron') {
      throw new MatchmakingServiceError(MatchmakingServiceErrorCode.InvalidClient, 'Invalid client')
    }

    return clientSockets
  }

  private publishToUser(userId: SbUserId, data?: ReadonlyDeep<MatchmakingEvent>) {
    this.publisher.publish(MatchmakingService.getUserPath(userId), data)
  }

  private publishToActiveClient(userId: SbUserId, data?: ReadonlyDeep<MatchmakingEvent>): boolean {
    const client = this.activityRegistry.getClientForUser(userId)
    if (client) {
      this.publisher.publish(MatchmakingService.getClientPath(client), data)
      return true
    } else {
      return false
    }
  }

  static getUserPath(userId: SbUserId) {
    return urlPath`/matchmaking/${userId}`
  }

  static getClientPath(client: ClientSocketsGroup) {
    return urlPath`/matchmaking/${client.userId}/${client.clientId}`
  }
}


text:
* Demo PoA tutorial · paritytech/parity Wiki https://github.com/paritytech/parity/wiki/Demo-PoA-tutorial
* Proof of Authority Chains · paritytech/parity Wiki https://github.com/paritytech/parity/wiki/Proof-of-Authority-Chains
* 使用Parity建立Proof-of-Authority (PoA) Ethereum Chain – Taipei Ethereum Meetup – Medium  https://medium.com/taipei-ethereum-meetup/%E4%BD%BF%E7%94%A8parity%E5%BB%BA%E7%AB%8Bproof-of-authority-poa-ethereum-chain-c5c1cdd0f21a

### parity-poa-playground

* https://github.com/dstarcev/parity-poa-playground

```
$ cd /tmp
$ git clone https://github.com/dstarcev/parity-poa-playground.git && cd parity-poa-playground
$ docker-compose up -d
$ docker-compose logs | grep token=
member0_1     | Open: http://0.0.0.0:8180/#/auth?token=3dU3-Dgs0-EjIc-sIJx
```

* member0 http://VMIP:8180/#/auth?token=3dU3-Dgs0-EjIc-sIJx
* ethstats dashboard http://VMIP:3001/

### 停止所有容器關掉網路

```
$ docker-compose stop
```